@giovannijecha/jecode 0.8.4 → 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 (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
@@ -1,336 +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 { compactContext } from "../context/compactor.js";
6
- import { compactSession } from "../context/manual.js";
7
- import { isContextOverflow } from "../context/policy.js";
8
- import { updateSettings } from "../settings.js";
9
- import { steeringInbox } from "../steering.js";
10
- import { saveTranscript } from "../transcript-export.js";
11
- import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "../usage.js";
12
- import { selectTimeline } from "../timeline.js";
13
- import { transition } from "./activity.js";
14
- import { answerAt } from "./approve.js";
15
- import * as edit from "./editor.js";
16
- import { cancel as cancelOpen } from "./overlay.js";
17
- import { controllerOptions, turnFailure } from "./session-view.js";
18
- import { transcribe } from "./turn.js";
19
- 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";
20
5
  export function appWorkflows(options) {
21
- const { session, state, permissions, feedback } = options;
22
- let activeSteering;
23
- const choose = (picker) => new Promise((resolve) => {
24
- state.open = { picker, settle: resolve };
25
- options.render();
26
- });
27
- async function command(text) {
28
- const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
29
- if (activity === undefined)
30
- return;
31
- const status = (label) => transition(activity, label);
32
- try {
33
- const outcome = await handleCommand(text, session, {
34
- emit: options.commandNotice,
35
- signal: activity.control.signal,
36
- showHelp: () => new Promise((resolve) => {
37
- state.open = { help: true, settle: resolve };
38
- options.render();
39
- }),
40
- choose,
41
- dismiss: () => {
42
- state.open = state.open === undefined ? undefined : cancelOpen(state.open);
43
- options.render();
44
- },
45
- type: (field) => new Promise((resolve) => {
46
- state.open = { field, settle: resolve };
47
- options.render();
48
- }),
49
- status: (said) => {
50
- status(said ?? activity.label);
51
- options.render();
52
- },
53
- reset: async () => {
54
- await session.persistence?.reset();
55
- state.blocks.splice(0);
56
- state.past.length = 0;
57
- permissions.reset();
58
- state.scroll = 0;
59
- state.follow = true;
60
- state.unseen = 0;
61
- state.lastMaxScroll = 0;
62
- state.committedNodeId = 0;
63
- },
64
- permissions,
65
- exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
66
- saveSettings: async (patch) => {
67
- await updateSettings(patch);
68
- },
69
- refreshSettings: options.refreshSettings,
70
- timeline: async () => {
71
- const selected = await selectTimeline(session, choose);
72
- if (!selected)
73
- return "unchanged";
74
- options.replaceTranscript();
75
- return session.conversation.activeNodeId === state.committedNodeId
76
- ? "unchanged"
77
- : "selected";
78
- },
79
- compact: async () => {
80
- if (session.conversation.activeNodeId !== state.committedNodeId) {
81
- return "branch-pending";
82
- }
83
- return compactSession(session, {
84
- signal: activity.control.signal,
85
- onStatus: (said) => {
86
- status(said ?? activity.label);
87
- options.render();
88
- },
89
- });
90
- },
91
- });
92
- if (outcome === "exit")
93
- state.closeWhenIdle = true;
94
- }
95
- catch (error) {
96
- // Command cancellation is already visible through the dock closing and
97
- // the activity ending. Keep it silent instead of replacing the footer
98
- // with a redundant warning.
99
- if (!activity.control.signal.aborted) {
100
- feedback.show({
101
- text: error.message,
102
- tone: "error",
103
- timeoutMs: 6_000,
104
- });
105
- }
106
- }
107
- finally {
108
- options.finishActivity(activity);
109
- }
110
- }
111
- async function turn(text) {
112
- const activity = options.startActivity("turn", WAITING);
113
- if (activity === undefined)
114
- return;
115
- const inbox = steeringInbox((pending, accepting) => {
116
- if (activeSteering !== inbox)
117
- return;
118
- state.steering = accepting ? pending : undefined;
119
- options.render();
120
- });
121
- activeSteering = inbox;
122
- state.steering = 0;
123
- const status = (label) => transition(activity, label);
124
- const parentId = session.conversation.activeNodeId;
125
- const history = session.conversation.history;
126
- const modelHistory = session.conversation.contextHistory;
127
- const historyStart = history.length;
128
- const blockStart = state.blocks.length;
129
- const createdAt = new Date().toISOString();
130
- const prospectiveNodeId = session.conversation.nodes.length + 1;
131
- let nodeId;
132
- let context;
133
- const unpersistedSteering = [];
134
- let firstPolicy = true;
135
- const policy = () => {
136
- let visible = firstPolicy;
137
- firstPolicy = false;
138
- if (visible) {
139
- status("Checking context");
140
- options.render();
141
- }
142
- return resolveContextPolicy({
143
- provider: session.provider,
144
- model: session.model,
145
- compactionPercent: session.config.compactionPercent,
146
- signal: activity.control.signal,
147
- onStatus: (said) => {
148
- visible = true;
149
- status(said);
150
- options.render();
151
- },
152
- }).finally(() => {
153
- if (visible) {
154
- status(WAITING);
155
- options.render();
156
- }
157
- });
158
- };
159
- options.emit({ kind: "user", text });
160
- const user = { role: "user", content: [{ kind: "text", text }] };
161
- history.push(user);
162
- modelHistory.push(structuredClone(user));
163
- const events = transcribe({
164
- emit: options.emit,
165
- render: options.render,
166
- palette: session.palette,
167
- approved: (call) => permissions.approved(call),
168
- remember: (call) => permissions.remember(call),
169
- ask: (prompt, settle) => {
170
- state.open = { picker: prompt, settle: (index) => settle(answerAt(index)) };
171
- options.render();
172
- },
173
- status: (text) => {
174
- status(text);
175
- },
176
- usage: (usage) => recordUsage(session.usage, usage),
177
- requestInput: (inputTokens) => recordRequestInput(session.usage, inputTokens),
178
- });
179
- const persist = async (checkpoint, settlement, failure) => {
180
- const next = session.conversation.commit({
181
- ...(nodeId === undefined ? {} : { nodeId }),
182
- parentId,
183
- createdAt,
184
- identity: {
185
- providerId: session.provider.id,
186
- model: session.model,
187
- effort: session.config.effort,
188
- },
189
- messages: checkpoint.slice(historyStart),
190
- blocks: state.blocks.slice(blockStart),
191
- ...(context === undefined ? {} : { context }),
192
- ...(failure === undefined ? {} : { failure }),
193
- }, settlement);
194
- await session.persistence?.checkpoint(next);
195
- session.conversation = next;
196
- nodeId = next.activeNodeId;
197
- state.committedNodeId = next.activeNodeId;
198
- unpersistedSteering.length = 0;
199
- };
200
- const compact = async (checkpoint, projected, request) => {
201
- if (request.reason === "overflow" &&
202
- (request.error === undefined || !isContextOverflow(request.error))) {
203
- return undefined;
204
- }
205
- const force = request.reason === "overflow";
206
- const result = await compactContext({
207
- provider: session.provider,
208
- model: session.model,
209
- effort: session.config.effort,
210
- context: projected,
211
- turn: checkpoint.slice(historyStart),
212
- nodeId: nodeId ?? prospectiveNodeId,
213
- coveredMessages: context?.messageCount ?? 0,
214
- lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
215
- estimatedInputTokens: request.inputTokens,
216
- signal: activity.control.signal,
217
- force,
218
- policy: request.policy,
219
- onBegin: () => {
220
- status("Compacting");
221
- options.render();
222
- },
223
- onEnd: () => {
224
- status(WAITING);
225
- options.render();
226
- },
227
- });
228
- if (result === undefined)
229
- return undefined;
230
- context = result.anchor;
231
- if (result.usage !== undefined)
232
- recordAuxiliaryUsage(session.usage, result.usage);
233
- return result.messages;
234
- };
235
- events.onContext = compact;
236
- events.onSteering = (guidance) => {
237
- unpersistedSteering.push(guidance);
238
- options.emit({ kind: "user", text: guidance });
239
- options.render();
240
- };
241
- events.onCheckpoint = async (checkpoint, settlement, projected) => {
242
- await persist(checkpoint, settlement);
243
- const compacted = await compact(checkpoint, projected, {
244
- reason: "budget",
245
- policy: await policy(),
246
- inputTokens: session.usage.lastInputTokens,
247
- });
248
- if (compacted !== undefined)
249
- await persist(checkpoint, settlement);
250
- return compacted;
251
- };
252
- let finishReason;
253
- let failed;
254
- try {
255
- await runTurn(history, controllerOptions(session, policy, permissions.availableTools(), inbox), events, activity.control.signal, modelHistory);
256
- }
257
- catch (error) {
258
- const interrupted = activity.control.signal.aborted;
259
- const completed = nodeId !== undefined && session.conversation.activeNodeId === nodeId &&
260
- session.conversation.activeNode?.settlement === "completed";
261
- if (completed) {
262
- const notice = turnFailure(session, error, interrupted);
263
- feedback.show({ text: notice.text, tone: notice.tone, timeoutMs: 6_000 });
264
- }
265
- else {
266
- finishReason = interrupted ? "interrupted" : "failed";
267
- failed = { error: error, interrupted };
268
- }
269
- }
270
- finally {
271
- let pendingSteering = inbox.close();
272
- if (activeSteering === inbox)
273
- activeSteering = undefined;
274
- state.steering = undefined;
275
- try {
276
- events.finish(finishReason);
277
- if (failed !== undefined) {
278
- const notice = turnFailure(session, failed.error, failed.interrupted);
279
- const settlement = failed.interrupted ? "interrupted" : "failed";
280
- const failure = {
281
- text: notice.text,
282
- tone: failed.interrupted ? "warn" : "error",
283
- };
284
- options.emit(notice);
285
- try {
286
- await persist(closeFailedTurn(history, settlement), settlement, failure);
287
- }
288
- catch (error) {
289
- // A failed persistence boundary cannot remain visible as if it had
290
- // been saved. Revert to the last durable path and return the input
291
- // to the composer so the user can retry without losing it.
292
- options.replaceTranscript();
293
- state.editor = edit.of([
294
- ...(nodeId === undefined ? [text] : []),
295
- ...unpersistedSteering,
296
- ...pendingSteering,
297
- state.editor.text,
298
- ].filter((part) => part !== "").join("\n\n"));
299
- state.completing = undefined;
300
- pendingSteering = [];
301
- feedback.show({
302
- text: error.message,
303
- tone: "error",
304
- timeoutMs: 6_000,
305
- });
306
- }
307
- }
308
- }
309
- finally {
310
- restorePendingSteering(state, pendingSteering);
311
- options.finishActivity(activity);
312
- }
313
- }
314
- }
315
- function steer(text) {
316
- return activeSteering?.offer(text) ?? "unavailable";
317
- }
318
- return { command, turn, steer };
319
- }
320
- function restorePendingSteering(state, messages) {
321
- if (messages.length === 0)
322
- return;
323
- const pending = messages.join("\n\n");
324
- state.editor = edit.of(state.editor.text === "" ? pending : `${pending}\n\n${state.editor.text}`);
325
- state.completing = undefined;
326
- }
327
- function closeFailedTurn(history, settlement) {
328
- const closed = [...history];
329
- if (closed.at(-1)?.role === "assistant")
330
- return closed;
331
- const text = settlement === "interrupted"
332
- ? "The previous attempt was interrupted by the user before completion."
333
- : "The previous attempt failed before completion.";
334
- closed.push({ role: "assistant", content: [{ kind: "text", text }] });
335
- return closed;
6
+ const automaticCompaction = automaticCompactionGate();
7
+ return {
8
+ command: commandWorkflow(options, () => automaticCompaction.reset()),
9
+ ...turnWorkflow(options, automaticCompaction),
10
+ };
336
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() {
@@ -10,7 +10,7 @@ export function renderFooter(info, status, width, pal) {
10
10
  function identity(info, cols) {
11
11
  if (cols <= 0)
12
12
  return "";
13
- const core = `${info.model || "no model"} · ${info.effort}`;
13
+ const core = `${info.provider || "Provider"} · ${info.model || "no model"} · ${info.effort}`;
14
14
  if (textWidth(core) >= cols || info.workspace === "")
15
15
  return elide(core, cols);
16
16
  const divider = " · ";
@@ -1,12 +1,57 @@
1
- // One visual row grammar for autocomplete and every interactive selector.
2
- import { hasColor, plainLen, row } from "../../ui/render.js";
3
- import { elide } from "../../ui/width.js";
1
+ // Ribbon rows and a stable detail area shared by completion and selectors.
2
+ import { row } from "../../ui/render.js";
3
+ import { terminalText } from "../../ui/terminal-text.js";
4
+ import { elide, textWidth, wrapText } from "../../ui/width.js";
4
5
  export function renderMenuRows(entries, width, pal) {
6
+ return entries.map((entry) => {
7
+ const right = summary(entry, width);
8
+ return row(width, [
9
+ { text: entry.selected ? "● " : " ", fg: pal.focus },
10
+ { text: entry.label, fg: entry.selected ? pal.ink.bright : pal.ink.muted, bold: entry.selected },
11
+ ], right === "" ? [] : [{ text: right, fg: entry.selected ? pal.focus : pal.ink.dim }], entry.selected ? pal.surface.subtle : undefined);
12
+ });
13
+ }
14
+ /** A missing palette measures the window without producing styled rows. */
15
+ export function renderMenu(entries, width, pal, options) {
16
+ const room = Math.max(0, options.maxRows);
17
+ if (room === 0)
18
+ return { rows: [], first: 0, last: 0 };
19
+ const detailLimit = Math.min(2, room - 1);
20
+ let detailRows = 0;
21
+ // Measure every possible selection to keep the dock stable without reserving
22
+ // a second row when all details fit on one line at this width.
23
+ for (const entry of entries) {
24
+ detailRows = Math.max(detailRows, detailLines(entry, width, detailLimit).length);
25
+ if (detailRows === detailLimit)
26
+ break;
27
+ }
28
+ const visible = Math.min(options.visible ?? 6, room - detailRows);
29
+ const at = Math.max(0, entries.findIndex((entry) => entry.selected));
30
+ const { first, last } = menuWindow(entries.length, at, visible);
31
+ if (pal === undefined)
32
+ return { rows: [], first, last };
5
33
  if (entries.length === 0)
34
+ return {
35
+ rows: [row(width, [{ text: "No matches. Change the filter.", fg: pal.ink.muted }])], first, last,
36
+ };
37
+ const active = entries.find((entry) => entry.selected);
38
+ const lines = detailLines(active, width, detailRows);
39
+ return {
40
+ rows: [
41
+ ...renderMenuRows(entries.slice(first, last), width, pal),
42
+ ...Array.from({ length: detailRows }, (_, index) => lines[index] === undefined ? ""
43
+ : row(width, [{ text: " " + lines[index], fg: pal.ink.muted }])),
44
+ ], first, last,
45
+ };
46
+ }
47
+ function detailLines(entry, width, maxRows) {
48
+ if (entry === undefined || maxRows <= 0)
6
49
  return [];
7
- const widest = Math.max(...entries.map(primaryWidth));
8
- const labelWidth = Math.min(42, Math.max(12, widest + 2));
9
- return entries.map((entry) => renderEntry(entry, labelWidth, width, pal));
50
+ // Complete identity and values take priority over explanatory copy.
51
+ const room = Math.max(1, width - 2);
52
+ const lines = menuText(clippedParts(entry, width).join(" · "), room, maxRows);
53
+ lines.push(...menuText(entry.description ?? "", room, maxRows - lines.length));
54
+ return lines;
10
55
  }
11
56
  export function menuWindow(length, selected, visible) {
12
57
  const count = Math.max(0, length);
@@ -15,42 +60,28 @@ export function menuWindow(length, selected, visible) {
15
60
  const first = Math.max(0, Math.min(at - Math.floor(room / 2), count - room));
16
61
  return { first, last: Math.min(count, first + room) };
17
62
  }
18
- function renderEntry(entry, labelWidth, width, pal) {
19
- // Colour terminals spend focus on the active label instead of painting a
20
- // full-width band. Monochrome has no colour, so it alone reserves a fixed
21
- // arrow column to keep selection visible without shifting peer rows.
22
- const monochrome = !hasColor();
23
- const selectedMark = monochrome ? (entry.selected ? "→ " : " ") : "";
24
- const fg = entry.selected ? pal.focus : pal.ink.fg;
25
- const primary = [
26
- { text: selectedMark, fg },
27
- { text: entry.label, fg, bold: entry.selected || undefined },
63
+ /** Wrap untrusted menu copy, marking a bounded final row when it omits text. */
64
+ export function menuText(text, width, maxRows) {
65
+ if (text === "" || maxRows <= 0)
66
+ return [];
67
+ const lines = wrapText(terminalText(text), Math.max(1, width));
68
+ return lines.slice(0, maxRows).map((line, index) => index === maxRows - 1 && lines.length > maxRows ? elide(line + " …", width) : line);
69
+ }
70
+ function fullSummary(entry) {
71
+ const value = entry.value === undefined ? "" : entry.selected && entry.adjustable ? "‹ " + entry.value + " ›" : entry.value;
72
+ return [entry.hint, value].filter(Boolean).join(" · ");
73
+ }
74
+ function summary(entry, width) {
75
+ return elide(terminalText(fullSummary(entry)), Math.max(1, Math.floor(width * 0.42)));
76
+ }
77
+ function clippedParts(entry, width) {
78
+ // Reserve the stepper's width for every peer, so moving selection cannot
79
+ // make the shared detail area appear or disappear.
80
+ const stable = entry.adjustable ? { ...entry, selected: true } : entry;
81
+ const right = summary(stable, width);
82
+ const leftRoom = Math.max(0, width - 2 - (right === "" ? 0 : textWidth(right) + 1));
83
+ return [
84
+ ...(textWidth(terminalText(entry.label)) > leftRoom ? [entry.label] : []),
85
+ ...(right !== terminalText(fullSummary(stable)) ? [fullSummary(stable)] : []),
28
86
  ];
29
- if (width > 40 && entry.description !== undefined) {
30
- const gap = Math.max(2, labelWidth - primaryWidth(entry));
31
- primary.push({
32
- text: `${" ".repeat(gap)}${entry.description}`,
33
- fg: entry.selected ? pal.ink.bright : pal.ink.muted,
34
- });
35
- }
36
- const value = entry.value === undefined
37
- ? undefined
38
- : entry.selected && entry.adjustable === true
39
- ? `‹ ${entry.value} ›`
40
- : entry.value;
41
- const summary = [entry.hint, value]
42
- .filter((part) => part !== undefined)
43
- .join(" · ");
44
- const rightColor = entry.selected ? pal.focus : pal.ink.muted;
45
- const right = summary === "" || (width <= 40 && entry.value === undefined)
46
- ? []
47
- : [{
48
- text: elide(summary, Math.max(1, Math.floor(entry.value === undefined ? width / 4 : width * 0.45))),
49
- fg: rightColor,
50
- bold: entry.selected || undefined,
51
- }];
52
- return row(width, primary, right);
53
- }
54
- function primaryWidth(entry) {
55
- return plainLen([{ text: entry.label }]);
56
87
  }