@giovannijecha/jecode 0.8.5 → 0.8.6

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 (64) hide show
  1. package/README.md +22 -5
  2. package/dist/batch-view.js +27 -2
  3. package/dist/batch.js +2 -0
  4. package/dist/cli-info.js +0 -1
  5. package/dist/config.js +14 -6
  6. package/dist/credential-commands.js +3 -3
  7. package/dist/openai-account-command.js +14 -12
  8. package/dist/openai-account.js +7 -5
  9. package/dist/openai-oauth-callback.js +13 -4
  10. package/dist/openai-oauth-tokens.js +9 -7
  11. package/dist/openai-oauth.js +8 -6
  12. package/dist/permission-command.js +1 -1
  13. package/dist/provider-commands.js +1 -33
  14. package/dist/provider-errors.js +1 -10
  15. package/dist/provider-label.js +3 -10
  16. package/dist/providers/anthropic.js +0 -1
  17. package/dist/providers/index.js +1 -5
  18. package/dist/providers/ollama-context.js +42 -0
  19. package/dist/providers/ollama-endpoint.js +7 -34
  20. package/dist/providers/ollama.js +13 -147
  21. package/dist/providers/openai-codex.js +4 -4
  22. package/dist/providers/openai.js +0 -1
  23. package/dist/sessions/bucket.js +55 -0
  24. package/dist/sessions/catalog-io.js +162 -0
  25. package/dist/sessions/catalog.js +3 -1
  26. package/dist/sessions/codec-messages.js +122 -0
  27. package/dist/sessions/codec-transcript.js +93 -0
  28. package/dist/sessions/codec-values.js +52 -0
  29. package/dist/sessions/codec.js +4 -257
  30. package/dist/sessions/files.js +158 -0
  31. package/dist/sessions/load.js +90 -0
  32. package/dist/sessions/snapshot.js +33 -0
  33. package/dist/sessions/store.js +42 -461
  34. package/dist/settings-command.js +10 -5
  35. package/dist/settings.js +16 -15
  36. package/dist/start.js +1 -2
  37. package/dist/tools/file-read.js +192 -0
  38. package/dist/tools/file-summary.js +9 -0
  39. package/dist/tools/{fs.js → file-write.js} +5 -193
  40. package/dist/tools/glob.js +107 -0
  41. package/dist/tools/index.js +2 -1
  42. package/dist/tools/search.js +1 -105
  43. package/dist/tui/app-workflows.js +8 -361
  44. package/dist/tui/approve.js +5 -3
  45. package/dist/tui/blocks.js +8 -7
  46. package/dist/tui/command-workflow.js +106 -0
  47. package/dist/tui/components/command-menu.js +7 -10
  48. package/dist/tui/components/menu.js +74 -43
  49. package/dist/tui/components/messages.js +16 -9
  50. package/dist/tui/components/tool-evidence.js +107 -0
  51. package/dist/tui/components/tool-motion.js +32 -0
  52. package/dist/tui/components/tool.js +48 -202
  53. package/dist/tui/help.js +1 -1
  54. package/dist/tui/picker-layout.js +40 -0
  55. package/dist/tui/picker.js +7 -71
  56. package/dist/tui/tool-details.js +135 -0
  57. package/dist/tui/transcript-grammar.js +8 -1
  58. package/dist/tui/transcript-view.js +26 -112
  59. package/dist/tui/turn-workflow.js +264 -0
  60. package/dist/tui/turn.js +7 -140
  61. package/dist/tui/workflow-types.js +2 -0
  62. package/package.json +12 -12
  63. package/dist/ollama-settings-command.js +0 -74
  64. package/dist/tui/motion.js +0 -32
@@ -6,12 +6,12 @@ import { readStableDirectory, StableDirectoryError, } from "../stable-directory.
6
6
  import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
7
7
  import { displayPath, resolveExistingInRoot } from "./paths.js";
8
8
  import { leadingText } from "./text-boundary.js";
9
+ import { glob } from "./glob.js";
9
10
  const DEFAULT_RESULTS = 100;
10
11
  const MAX_RESULTS = 500;
11
12
  const MAX_VISITED = 20_000;
12
13
  const MAX_FILE_BYTES = 1_000_000;
13
14
  const MAX_MATCH_LINE = 500;
14
- const MAX_GLOB_CHARS = 512;
15
15
  const PORTABLE_SEARCH_CONCURRENCY = 8;
16
16
  const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
17
17
  export const findFiles = {
@@ -255,110 +255,6 @@ function resultLimit(args) {
255
255
  throw new Error('"max_results" must be a positive integer');
256
256
  return Math.min(requested, MAX_RESULTS);
257
257
  }
258
- function glob(pattern) {
259
- const normalized = pattern.replace(/\\/g, "/");
260
- if (normalized.length > MAX_GLOB_CHARS) {
261
- throw new Error(`"pattern" must be at most ${MAX_GLOB_CHARS} characters`);
262
- }
263
- const tokens = tokenizeGlob(normalized.toLowerCase());
264
- const basenameOnly = !normalized.includes("/");
265
- return (relative) => {
266
- const candidate = relative.replace(/\\/g, "/");
267
- const target = (basenameOnly ? path.posix.basename(candidate) : candidate).toLowerCase();
268
- return matchGlob(tokens, Array.from(target));
269
- };
270
- }
271
- function tokenizeGlob(pattern) {
272
- const chars = Array.from(pattern);
273
- const tokens = [];
274
- let index = 0;
275
- while (index < chars.length) {
276
- const char = chars[index];
277
- if (char === "*") {
278
- let end = index + 1;
279
- while (chars[end] === "*")
280
- end++;
281
- if (end - index >= 2) {
282
- if (chars[end] === "/") {
283
- tokens.push({ kind: "globdir-start" }, { kind: "globdir-body" });
284
- index = end + 1;
285
- }
286
- else {
287
- tokens.push({ kind: "globstar" });
288
- index = end;
289
- }
290
- }
291
- else {
292
- tokens.push({ kind: "star" });
293
- index = end;
294
- }
295
- continue;
296
- }
297
- tokens.push(char === "?" ? { kind: "one" } : { kind: "literal", value: char });
298
- index++;
299
- }
300
- return tokens;
301
- }
302
- /** Thompson-style wildcard matching: O(pattern × path), with no regex backtracking. */
303
- function matchGlob(tokens, text) {
304
- let states = epsilonClosure(new Set([0]), tokens);
305
- for (const char of text) {
306
- const next = new Set();
307
- for (const state of states) {
308
- const token = tokens[state];
309
- if (token === undefined)
310
- continue;
311
- switch (token.kind) {
312
- case "literal":
313
- if (token.value === char)
314
- next.add(state + 1);
315
- break;
316
- case "one":
317
- if (char !== "/")
318
- next.add(state + 1);
319
- break;
320
- case "star":
321
- if (char !== "/")
322
- next.add(state);
323
- break;
324
- case "globstar":
325
- next.add(state);
326
- break;
327
- case "globdir-body":
328
- next.add(state);
329
- if (char === "/")
330
- next.add(state + 1);
331
- break;
332
- case "globdir-start":
333
- break;
334
- }
335
- }
336
- states = epsilonClosure(next, tokens);
337
- if (states.size === 0)
338
- return false;
339
- }
340
- return epsilonClosure(states, tokens).has(tokens.length);
341
- }
342
- function epsilonClosure(seed, tokens) {
343
- const states = new Set(seed);
344
- const pending = [...seed];
345
- while (pending.length > 0) {
346
- const state = pending.pop();
347
- const token = tokens[state];
348
- const targets = token?.kind === "globdir-start"
349
- ? [state + 1, state + 2]
350
- : token?.kind === "star" || token?.kind === "globstar"
351
- ? [state + 1]
352
- : [];
353
- for (const target of targets) {
354
- if (states.has(target))
355
- continue;
356
- states.add(target);
357
- pending.push(target);
358
- }
359
- }
360
- return states;
361
- }
362
258
  function summary(count, limit, capped, one, many) {
363
259
  const noun = count === 1 ? one : many;
364
260
  if (count >= limit)
@@ -1,364 +1,11 @@
1
- // Foreground command and model-turn workflows for the TUI shell.
2
- import { handleCommand } from "../commands.js";
3
- import { runTurn } from "../controller.js";
4
- import { resolveContextPolicy } from "../context/capacity.js";
5
- import { automaticCompactionGate, automaticCompactionKey, } from "../context/automatic.js";
6
- import { compactContext } from "../context/compactor.js";
7
- import { compactSession } from "../context/manual.js";
8
- import { isContextOverflow } from "../context/policy.js";
9
- import { updateSettings } from "../settings.js";
10
- import { steeringInbox } from "../steering.js";
11
- import { saveTranscript } from "../transcript-export.js";
12
- import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "../usage.js";
13
- import { selectTimeline } from "../timeline.js";
14
- import { toolSpecs } from "../tools/index.js";
15
- import { resetRequestIdentity, requestIdentityForSession } from "../request-identity.js";
16
- import { transition } from "./activity.js";
17
- import { answerAt } from "./approve.js";
18
- import * as edit from "./editor.js";
19
- import { cancel as cancelOpen } from "./overlay.js";
20
- import { controllerOptions, turnFailure } from "./session-view.js";
21
- import { transcribe } from "./turn.js";
22
- const WAITING = "Waiting";
1
+ // Wire foreground workflows to the shell and their shared compaction lifetime.
2
+ import { automaticCompactionGate } from "../context/automatic.js";
3
+ import { commandWorkflow } from "./command-workflow.js";
4
+ import { turnWorkflow } from "./turn-workflow.js";
23
5
  export function appWorkflows(options) {
24
- const { session, state, permissions, feedback } = options;
25
- let activeSteering;
26
6
  const automaticCompaction = automaticCompactionGate();
27
- const choose = (picker) => new Promise((resolve) => {
28
- state.open = { picker, settle: resolve };
29
- options.render();
30
- });
31
- async function command(text) {
32
- const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
33
- if (activity === undefined)
34
- return;
35
- const status = (label) => transition(activity, label);
36
- try {
37
- const outcome = await handleCommand(text, session, {
38
- emit: options.commandNotice,
39
- signal: activity.control.signal,
40
- showHelp: () => new Promise((resolve) => {
41
- state.open = { help: true, settle: resolve };
42
- options.render();
43
- }),
44
- choose,
45
- dismiss: () => {
46
- state.open = state.open === undefined ? undefined : cancelOpen(state.open);
47
- options.render();
48
- },
49
- type: (field) => new Promise((resolve) => {
50
- state.open = { field, settle: resolve };
51
- options.render();
52
- }),
53
- status: (said) => {
54
- status(said ?? activity.label);
55
- options.render();
56
- },
57
- reset: async () => {
58
- await session.persistence?.reset();
59
- resetRequestIdentity(session);
60
- automaticCompaction.reset();
61
- state.blocks.splice(0);
62
- state.past.length = 0;
63
- permissions.reset();
64
- state.scroll = 0;
65
- state.follow = true;
66
- state.unseen = 0;
67
- state.lastMaxScroll = 0;
68
- state.committedNodeId = 0;
69
- },
70
- permissions,
71
- exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
72
- saveSettings: async (patch) => {
73
- await updateSettings(patch);
74
- },
75
- refreshSettings: options.refreshSettings,
76
- timeline: async () => {
77
- const selected = await selectTimeline(session, choose);
78
- if (!selected)
79
- return "unchanged";
80
- options.replaceTranscript();
81
- return session.conversation.activeNodeId === state.committedNodeId
82
- ? "unchanged"
83
- : "selected";
84
- },
85
- compact: async () => {
86
- if (session.conversation.activeNodeId !== state.committedNodeId) {
87
- return "branch-pending";
88
- }
89
- const result = await compactSession(session, {
90
- signal: activity.control.signal,
91
- onStatus: (said) => {
92
- status(said ?? activity.label);
93
- options.render();
94
- },
95
- });
96
- if (result === "compacted")
97
- automaticCompaction.reset();
98
- return result;
99
- },
100
- });
101
- if (outcome === "exit")
102
- state.closeWhenIdle = true;
103
- }
104
- catch (error) {
105
- // Command cancellation is already visible through the dock closing and
106
- // the activity ending. Keep it silent instead of replacing the footer
107
- // with a redundant warning.
108
- if (!activity.control.signal.aborted) {
109
- feedback.show({
110
- text: error.message,
111
- tone: "error",
112
- timeoutMs: 6_000,
113
- });
114
- }
115
- }
116
- finally {
117
- options.finishActivity(activity);
118
- }
119
- }
120
- async function turn(text) {
121
- const activity = options.startActivity("turn", WAITING);
122
- if (activity === undefined)
123
- return;
124
- const inbox = steeringInbox((pending, accepting) => {
125
- if (activeSteering !== inbox)
126
- return;
127
- state.steering = accepting ? pending : undefined;
128
- options.render();
129
- });
130
- activeSteering = inbox;
131
- state.steering = 0;
132
- const status = (label) => transition(activity, label);
133
- const parentId = session.conversation.activeNodeId;
134
- const history = session.conversation.history;
135
- const modelHistory = session.conversation.contextHistory;
136
- const historyStart = history.length;
137
- const blockStart = state.blocks.length;
138
- const createdAt = new Date().toISOString();
139
- const prospectiveNodeId = session.conversation.nodes.length + 1;
140
- let nodeId;
141
- let context;
142
- const unpersistedSteering = [];
143
- const turnTools = permissions.availableTools();
144
- const specs = toolSpecs(turnTools);
145
- let firstPolicy = true;
146
- const policy = () => {
147
- let visible = firstPolicy;
148
- firstPolicy = false;
149
- if (visible) {
150
- status("Checking context");
151
- options.render();
152
- }
153
- return resolveContextPolicy({
154
- provider: session.provider,
155
- model: session.model,
156
- compactionPercent: session.config.compactionPercent,
157
- signal: activity.control.signal,
158
- onStatus: (said) => {
159
- visible = true;
160
- status(said);
161
- options.render();
162
- },
163
- }).finally(() => {
164
- if (visible) {
165
- status(WAITING);
166
- options.render();
167
- }
168
- });
169
- };
170
- options.emit({ kind: "user", text });
171
- const user = { role: "user", content: [{ kind: "text", text }] };
172
- history.push(user);
173
- modelHistory.push(structuredClone(user));
174
- const events = transcribe({
175
- emit: options.emit,
176
- render: options.render,
177
- palette: session.palette,
178
- approved: (call) => permissions.approved(call),
179
- remember: (call) => permissions.remember(call),
180
- ask: (prompt, settle) => {
181
- state.open = { picker: prompt, settle: (index) => settle(answerAt(index)) };
182
- options.render();
183
- },
184
- status: (text) => {
185
- status(text);
186
- },
187
- usage: (usage) => recordUsage(session.usage, usage),
188
- requestInput: (inputTokens) => recordRequestInput(session.usage, inputTokens),
189
- });
190
- const persist = async (checkpoint, settlement, failure) => {
191
- const next = session.conversation.commit({
192
- ...(nodeId === undefined ? {} : { nodeId }),
193
- parentId,
194
- createdAt,
195
- identity: {
196
- providerId: session.provider.id,
197
- model: session.model,
198
- effort: session.config.effort,
199
- },
200
- messages: checkpoint.slice(historyStart),
201
- blocks: state.blocks.slice(blockStart),
202
- ...(context === undefined ? {} : { context }),
203
- ...(failure === undefined ? {} : { failure }),
204
- }, settlement);
205
- await session.persistence?.checkpoint(next);
206
- session.conversation = next;
207
- nodeId = next.activeNodeId;
208
- state.committedNodeId = next.activeNodeId;
209
- unpersistedSteering.length = 0;
210
- };
211
- const compact = async (checkpoint, projected, request) => {
212
- if (request.reason === "overflow" &&
213
- (request.error === undefined || !isContextOverflow(request.error))) {
214
- return undefined;
215
- }
216
- const force = request.reason === "overflow" || request.projectionSaturated;
217
- const key = automaticCompactionKey(session.provider.id, session.model, nodeId ?? prospectiveNodeId, checkpoint.length);
218
- const attempt = { key, reason: request.reason };
219
- if (!automaticCompaction.allows(attempt))
220
- return undefined;
221
- let attempted = false;
222
- const result = await compactContext({
223
- provider: session.provider,
224
- model: session.model,
225
- effort: session.config.effort,
226
- context: projected,
227
- turn: checkpoint.slice(historyStart),
228
- nodeId: nodeId ?? prospectiveNodeId,
229
- coveredMessages: context?.messageCount ?? 0,
230
- lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
231
- estimatedInputTokens: request.inputTokens,
232
- signal: activity.control.signal,
233
- force,
234
- policy: request.policy,
235
- requestEnvelope: {
236
- system: session.system,
237
- tools: specs,
238
- maxOutputTokens: session.config.maxTokens,
239
- },
240
- requestIdentity: requestIdentityForSession(session),
241
- onBegin: () => {
242
- attempted = true;
243
- status("Compacting");
244
- options.render();
245
- },
246
- onEnd: () => {
247
- status(WAITING);
248
- options.render();
249
- },
250
- });
251
- if (result === undefined) {
252
- if (attempted && !activity.control.signal.aborted)
253
- automaticCompaction.failed(attempt);
254
- return undefined;
255
- }
256
- automaticCompaction.succeeded(attempt);
257
- context = result.anchor;
258
- if (result.usage !== undefined)
259
- recordAuxiliaryUsage(session.usage, result.usage);
260
- return result.messages;
261
- };
262
- events.onContext = compact;
263
- events.onSteering = (guidance) => {
264
- unpersistedSteering.push(guidance);
265
- options.emit({ kind: "user", text: guidance });
266
- options.render();
267
- };
268
- events.onCheckpoint = async (checkpoint, settlement, projected) => {
269
- await persist(checkpoint, settlement);
270
- const compacted = await compact(checkpoint, projected, {
271
- reason: "budget",
272
- policy: await policy(),
273
- inputTokens: session.usage.lastInputTokens,
274
- projectionSaturated: false,
275
- });
276
- if (compacted !== undefined)
277
- await persist(checkpoint, settlement);
278
- return compacted;
279
- };
280
- let finishReason;
281
- let failed;
282
- try {
283
- await runTurn(history, controllerOptions(session, policy, turnTools, inbox), events, activity.control.signal, modelHistory);
284
- }
285
- catch (error) {
286
- const interrupted = activity.control.signal.aborted;
287
- const completed = nodeId !== undefined && session.conversation.activeNodeId === nodeId &&
288
- session.conversation.activeNode?.settlement === "completed";
289
- if (completed) {
290
- const notice = turnFailure(session, error, interrupted);
291
- feedback.show({ text: notice.text, tone: notice.tone, timeoutMs: 6_000 });
292
- }
293
- else {
294
- finishReason = interrupted ? "interrupted" : "failed";
295
- failed = { error: error, interrupted };
296
- }
297
- }
298
- finally {
299
- let pendingSteering = inbox.close();
300
- if (activeSteering === inbox)
301
- activeSteering = undefined;
302
- state.steering = undefined;
303
- try {
304
- events.finish(finishReason);
305
- if (failed !== undefined) {
306
- const notice = turnFailure(session, failed.error, failed.interrupted);
307
- const settlement = failed.interrupted ? "interrupted" : "failed";
308
- const failure = {
309
- text: notice.text,
310
- tone: failed.interrupted ? "warn" : "error",
311
- };
312
- options.emit(notice);
313
- try {
314
- await persist(closeFailedTurn(history, settlement), settlement, failure);
315
- }
316
- catch (error) {
317
- // A failed persistence boundary cannot remain visible as if it had
318
- // been saved. Revert to the last durable path and return the input
319
- // to the composer so the user can retry without losing it.
320
- options.replaceTranscript();
321
- state.editor = edit.of([
322
- ...(nodeId === undefined ? [text] : []),
323
- ...unpersistedSteering,
324
- ...pendingSteering,
325
- state.editor.text,
326
- ].filter((part) => part !== "").join("\n\n"));
327
- state.completing = undefined;
328
- pendingSteering = [];
329
- feedback.show({
330
- text: error.message,
331
- tone: "error",
332
- timeoutMs: 6_000,
333
- });
334
- }
335
- }
336
- }
337
- finally {
338
- restorePendingSteering(state, pendingSteering);
339
- options.finishActivity(activity);
340
- }
341
- }
342
- }
343
- function steer(text) {
344
- return activeSteering?.offer(text) ?? "unavailable";
345
- }
346
- return { command, turn, steer };
347
- }
348
- function restorePendingSteering(state, messages) {
349
- if (messages.length === 0)
350
- return;
351
- const pending = messages.join("\n\n");
352
- state.editor = edit.of(state.editor.text === "" ? pending : `${pending}\n\n${state.editor.text}`);
353
- state.completing = undefined;
354
- }
355
- function closeFailedTurn(history, settlement) {
356
- const closed = [...history];
357
- if (closed.at(-1)?.role === "assistant")
358
- return closed;
359
- const text = settlement === "interrupted"
360
- ? "The previous attempt was interrupted by the user before completion."
361
- : "The previous attempt failed before completion.";
362
- closed.push({ role: "assistant", content: [{ kind: "text", text }] });
363
- return closed;
7
+ return {
8
+ command: commandWorkflow(options, () => automaticCompaction.reset()),
9
+ ...turnWorkflow(options, automaticCompaction),
10
+ };
364
11
  }
@@ -16,13 +16,15 @@ export { scopeFor };
16
16
  export function promptFor(call, target, pal) {
17
17
  const scope = scopeFor(call);
18
18
  const options = [
19
- { label: "Yes, once", hint: "enter", key: "y" },
20
- { label: `Yes, ${scopeNoun(scope)} for the session`, hint: "a", key: "a" },
21
- { label: "No, and say why", hint: "esc", key: "n" },
19
+ { label: "Yes, once", hint: "y", key: "y", description: "Approve only this call. Ask again next time." },
20
+ { label: `Yes, ${scopeNoun(scope)} for the session`, hint: "a", key: "a",
21
+ description: `Reuse approval for ${scopeNoun(scope)} in this session.` },
22
+ { label: "No, and say why", hint: "n", key: "n", description: "Do not run this call. Return feedback to the model." },
22
23
  ];
23
24
  return {
24
25
  title: [{ text: question(call.name), fg: pal.ink.attention, bold: true }],
25
26
  right: `${call.name}${target === "" ? "" : ` · ${target}`}`,
27
+ controls: "↑↓ choose · enter confirm · esc deny",
26
28
  options,
27
29
  index: 0,
28
30
  };
@@ -2,23 +2,24 @@
2
2
  import { renderAnswer, renderReasoning, renderUser } from "./components/messages.js";
3
3
  import { renderNotice } from "./components/misc.js";
4
4
  import { renderTool } from "./components/tool.js";
5
+ import { insetTranscript, modelTranscriptWidth } from "./transcript-grammar.js";
5
6
  export function render(block, width, pal, context = {}) {
7
+ const inner = modelTranscriptWidth(width);
6
8
  switch (block.kind) {
7
9
  case "user":
8
10
  return renderUser(block, width, pal);
9
11
  case "answer":
10
- return renderAnswer(block, width, pal);
12
+ return insetTranscript(renderAnswer(block, inner, pal));
11
13
  case "reasoning":
12
- return renderReasoning(block, width, pal, {
14
+ return insetTranscript(renderReasoning(block, inner, pal, {
13
15
  continues: context.previous?.kind === "reasoning",
14
- });
16
+ }));
15
17
  case "tool":
16
- return renderTool(block, width, pal, {
17
- continues: context.previous?.kind === "tool",
18
+ return insetTranscript(renderTool(block, inner, pal, {
19
+ continues: context.previous?.kind === "reasoning",
18
20
  now: context.now,
19
- motion: context.motion,
20
21
  reducedMotion: context.reducedMotion,
21
- });
22
+ }));
22
23
  case "notice":
23
24
  return renderNotice(block, width, pal);
24
25
  }
@@ -0,0 +1,106 @@
1
+ // Foreground slash-command interactions and their session updates.
2
+ import { handleCommand } from "../commands.js";
3
+ import { compactSession } from "../context/manual.js";
4
+ import { resetRequestIdentity } from "../request-identity.js";
5
+ import { updateSettings } from "../settings.js";
6
+ import { selectTimeline } from "../timeline.js";
7
+ import { saveTranscript } from "../transcript-export.js";
8
+ import { transition } from "./activity.js";
9
+ import { cancel as cancelOpen } from "./overlay.js";
10
+ export function commandWorkflow(options, resetCompaction) {
11
+ const { session, state, permissions, feedback } = options;
12
+ const choose = (picker) => new Promise((resolve) => {
13
+ state.open = { picker, settle: resolve };
14
+ options.render();
15
+ });
16
+ async function command(text) {
17
+ const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
18
+ if (activity === undefined)
19
+ return;
20
+ const status = (label) => transition(activity, label);
21
+ try {
22
+ const outcome = await handleCommand(text, session, {
23
+ emit: options.commandNotice,
24
+ signal: activity.control.signal,
25
+ showHelp: () => new Promise((resolve) => {
26
+ state.open = { help: true, settle: resolve };
27
+ options.render();
28
+ }),
29
+ choose,
30
+ dismiss: () => {
31
+ state.open = state.open === undefined ? undefined : cancelOpen(state.open);
32
+ options.render();
33
+ },
34
+ type: (field) => new Promise((resolve) => {
35
+ state.open = { field, settle: resolve };
36
+ options.render();
37
+ }),
38
+ status: (said) => {
39
+ status(said ?? activity.label);
40
+ options.render();
41
+ },
42
+ reset: async () => {
43
+ await session.persistence?.reset();
44
+ resetRequestIdentity(session);
45
+ resetCompaction();
46
+ state.blocks.splice(0);
47
+ state.past.length = 0;
48
+ permissions.reset();
49
+ state.scroll = 0;
50
+ state.follow = true;
51
+ state.unseen = 0;
52
+ state.lastMaxScroll = 0;
53
+ state.committedNodeId = 0;
54
+ },
55
+ permissions,
56
+ exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
57
+ saveSettings: async (patch) => {
58
+ await updateSettings(patch);
59
+ },
60
+ refreshSettings: options.refreshSettings,
61
+ timeline: async () => {
62
+ const selected = await selectTimeline(session, choose);
63
+ if (!selected)
64
+ return "unchanged";
65
+ options.replaceTranscript();
66
+ return session.conversation.activeNodeId === state.committedNodeId
67
+ ? "unchanged"
68
+ : "selected";
69
+ },
70
+ compact: async () => {
71
+ if (session.conversation.activeNodeId !== state.committedNodeId) {
72
+ return "branch-pending";
73
+ }
74
+ const result = await compactSession(session, {
75
+ signal: activity.control.signal,
76
+ onStatus: (said) => {
77
+ status(said ?? activity.label);
78
+ options.render();
79
+ },
80
+ });
81
+ if (result === "compacted")
82
+ resetCompaction();
83
+ return result;
84
+ },
85
+ });
86
+ if (outcome === "exit")
87
+ state.closeWhenIdle = true;
88
+ }
89
+ catch (error) {
90
+ // Command cancellation is already visible through the dock closing and
91
+ // the activity ending. Keep it silent instead of replacing the footer
92
+ // with a redundant warning.
93
+ if (!activity.control.signal.aborted) {
94
+ feedback.show({
95
+ text: error.message,
96
+ tone: "error",
97
+ timeoutMs: 6_000,
98
+ });
99
+ }
100
+ }
101
+ finally {
102
+ options.finishActivity(activity);
103
+ }
104
+ }
105
+ return command;
106
+ }
@@ -1,18 +1,15 @@
1
- import { menuWindow, renderMenuRows } from "./menu.js";
1
+ import { renderMenu } from "./menu.js";
2
2
  const MAX_ROWS = 4;
3
3
  export function renderCommandMenu(commands, selected, width, pal) {
4
4
  const selectedIndex = selected === undefined ? 0 : Math.max(0, Math.min(commands.length - 1, selected));
5
- const { first, last } = menuWindow(commands.length, selectedIndex, MAX_ROWS);
6
- const shown = commands.slice(first, last);
7
- if (shown.length === 0)
5
+ if (commands.length === 0)
8
6
  return { rows: [], right: "" };
7
+ const menu = renderMenu(commands.map((command, index) => ({
8
+ label: `/${command.name}`, description: command.blurb, selected: selectedIndex === index,
9
+ })), width, pal, { maxRows: MAX_ROWS + 2, visible: MAX_ROWS });
9
10
  return {
10
- rows: renderMenuRows(shown.map((command, index) => ({
11
- label: `/${command.name}`,
12
- description: command.blurb,
13
- selected: selectedIndex === first + index,
14
- })), width, pal),
15
- right: commands.length > shown.length ? `${first + 1}–${last} / ${commands.length}` : "",
11
+ rows: menu.rows,
12
+ right: commands.length > menu.last - menu.first ? `${menu.first + 1}–${menu.last} / ${commands.length}` : "",
16
13
  };
17
14
  }
18
15
  export function commandMenuLimit() {