@antst/dashi 0.1.0-alpha.19 → 0.1.0-alpha.21

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 (53) hide show
  1. package/lib/application.d.ts +45 -0
  2. package/lib/application.js +273 -0
  3. package/lib/at-trigger.d.ts +8 -0
  4. package/lib/at-trigger.js +13 -0
  5. package/lib/catalogs.d.ts +28 -0
  6. package/lib/catalogs.js +308 -0
  7. package/lib/clipboard-image.d.ts +3 -0
  8. package/lib/clipboard-image.js +74 -0
  9. package/lib/completion-trigger.d.ts +6 -0
  10. package/lib/completion-trigger.js +19 -0
  11. package/lib/diff-view.d.ts +5 -0
  12. package/lib/diff-view.js +30 -0
  13. package/lib/external-editor.d.ts +3 -0
  14. package/lib/external-editor.js +39 -0
  15. package/lib/help.d.ts +2 -0
  16. package/lib/help.js +6 -0
  17. package/lib/history-records.d.ts +4 -0
  18. package/lib/history-records.js +4 -0
  19. package/lib/image-input.d.ts +8 -0
  20. package/lib/image-input.js +37 -0
  21. package/lib/index.d.ts +4 -0
  22. package/lib/index.js +325 -0
  23. package/lib/input.d.ts +17 -0
  24. package/lib/input.js +267 -0
  25. package/lib/memory.d.ts +3 -0
  26. package/lib/memory.js +27 -0
  27. package/lib/mouse-input.d.ts +1 -0
  28. package/lib/mouse-input.js +6 -0
  29. package/lib/presentation.d.ts +9 -0
  30. package/lib/presentation.js +54 -0
  31. package/lib/renderer.d.ts +21 -0
  32. package/lib/renderer.js +491 -0
  33. package/lib/rewind.d.ts +31 -0
  34. package/lib/rewind.js +114 -0
  35. package/lib/session-export.d.ts +3 -0
  36. package/lib/session-export.js +27 -0
  37. package/lib/session-list.d.ts +15 -0
  38. package/lib/session-list.js +65 -0
  39. package/lib/session-runtime.d.ts +49 -0
  40. package/lib/session-runtime.js +1492 -0
  41. package/lib/shell-command.d.ts +10 -0
  42. package/lib/shell-command.js +66 -0
  43. package/lib/state.d.ts +517 -0
  44. package/lib/state.js +685 -0
  45. package/lib/terminal-guard.d.ts +14 -0
  46. package/lib/terminal-guard.js +76 -0
  47. package/lib/tool-presentation.d.ts +14 -0
  48. package/lib/tool-presentation.js +156 -0
  49. package/lib/transcript.d.ts +14 -0
  50. package/lib/transcript.js +336 -0
  51. package/lib/tui-root.d.ts +20 -0
  52. package/lib/tui-root.js +25 -0
  53. package/package.json +1 -1
package/lib/state.js ADDED
@@ -0,0 +1,685 @@
1
+ import { FLAG_HELP, VERSION_LINE } from './help.js';
2
+ export function initialViewState(cwd, inline, root, cells = [], prompts = [], historyHasMore = false, attachments = [], toolMode = 'collapsed') {
3
+ return {
4
+ attachments, cells, composer: '', cwd, decisions: [], exitArmed: false, historyHasMore, inline,
5
+ newOutput: 0, prompts, rewindArmed: false, scrollOffset: 0,
6
+ sendMode: 'steer', toolMode,
7
+ ...(root === undefined ? {} : { root }),
8
+ };
9
+ }
10
+ export const TURN_BELL_THRESHOLD_MS = 10_000;
11
+ function redraw() {
12
+ return [{ type: 'redraw', force: false }];
13
+ }
14
+ function focusAfter(state, decisions) {
15
+ const next = decisions[0];
16
+ return { type: 'set-composer', text: next?.kind === 'question' ? next.custom : state.composer };
17
+ }
18
+ function removeHead(state, effect) {
19
+ const decisions = state.decisions.slice(1);
20
+ return [{ ...state, decisions }, [effect, focusAfter(state, decisions), ...redraw()]];
21
+ }
22
+ function moveCursor(decision, cursor) {
23
+ if (decision.kind === 'approval')
24
+ return { ...decision, cursor: Math.max(0, Math.min(2, cursor)) };
25
+ const question = decision.questions[decision.index];
26
+ if (question === undefined)
27
+ return decision;
28
+ return { ...decision, cursor: Math.max(0, Math.min(question.options.length - (question.allowCustom === false ? 1 : 0), cursor)) };
29
+ }
30
+ function help() {
31
+ return {
32
+ kind: 'info',
33
+ title: 'Help',
34
+ lines: [
35
+ VERSION_LINE,
36
+ 'Enter send · Ctrl+J newline · Ctrl+T steer/next turn',
37
+ 'Esc interrupt · Ctrl+C clear/interrupt · Ctrl+C/D again within 2s to exit',
38
+ '/ and @ complete live · Tab insert · Shift+Tab permission · Ctrl+O cards',
39
+ '↑↓ prompt recall · Ctrl+R recall search · Ctrl+F transcript search',
40
+ 'PageUp/PageDown scroll · Ctrl+Home/Ctrl+End ends · Esc Esc rewind/draft recall',
41
+ 'Ctrl+B activity details for jobs and subagents',
42
+ '@ paths require DSH file-reference · Ctrl+V pastes an image · Backspace removes it',
43
+ 'Ctrl+S stash · Ctrl+G editor · F1 help · Ctrl+L redraw · Ctrl+Z suspend',
44
+ '/new /clear /reset /resume /continue [--all] [NAME|UUID] /fork /branch /rewind /rename /model /effort /permission /agents /queue /status /usage /context',
45
+ '/init /memory /skills /config /login /logout /diff /plugins /plugin /tasks /bashes /subtask /loop /btw /recap /history /export /copy /exit /quit',
46
+ ...FLAG_HELP,
47
+ ],
48
+ };
49
+ }
50
+ function moveOverlay(overlay, cursor, detailsCount = 0) {
51
+ if (overlay.kind === 'info' || overlay.kind === 'history')
52
+ return overlay;
53
+ const limit = overlay.kind === 'confirm' ? 1
54
+ : overlay.kind === 'details' ? Math.max(0, detailsCount - 1)
55
+ : Math.max(0, overlay.options.length - 1);
56
+ return { ...overlay, cursor: Math.max(0, Math.min(limit, cursor)) };
57
+ }
58
+ export const PAGE_CELLS = 8;
59
+ function matches(cells, query) {
60
+ const needle = query.trim().toLocaleLowerCase();
61
+ if (needle === '')
62
+ return [];
63
+ const found = [];
64
+ cells.forEach((cell, index) => {
65
+ const tool = cell.tool;
66
+ const haystack = [cell.text, cell.detail, tool?.title, tool?.body,
67
+ ...(tool?.diffs?.flatMap(diff => [diff.path, ...diff.lines]) ?? [])]
68
+ .filter((value) => value !== undefined).join('\n').toLocaleLowerCase();
69
+ if (haystack.includes(needle))
70
+ found.push(index);
71
+ });
72
+ return found;
73
+ }
74
+ function copyText(cell) {
75
+ const card = cell.tool;
76
+ if (card === undefined)
77
+ return cell.text;
78
+ const detail = card.diffs === undefined
79
+ ? card.body
80
+ : card.diffs.flatMap(diff => [diff.path, ...diff.lines]).join('\n');
81
+ return detail === undefined || detail === '' ? card.title : `${card.title}\n${detail}`;
82
+ }
83
+ function refreshSearch(search, cells) {
84
+ if (search === undefined)
85
+ return undefined;
86
+ const found = matches(cells, search.query);
87
+ return { ...search, cursor: Math.min(search.cursor, Math.max(0, found.length - 1)), matches: found };
88
+ }
89
+ function completedAssistant(cells, nth) {
90
+ let seen = 0;
91
+ for (let index = cells.length - 1; index >= 0; index--) {
92
+ const cell = cells[index];
93
+ if (cell?.kind === 'assistant' && cell.pending !== true && ++seen === nth)
94
+ return cell;
95
+ }
96
+ }
97
+ function codeBlockOptions(text) {
98
+ const fence = /(?:^|\r?\n)(`{3,}|~{3,})([^\r\n]*)\r?\n([\s\S]*?)\r?\n\1[ \t]*(?=\r?\n|$)/gu;
99
+ return [...text.matchAll(fence)].map(match => {
100
+ const code = match[3] ?? '';
101
+ return {
102
+ label: `${match[2]?.trim() || 'text'} · ${code.split(/\r?\n/u)[0] ?? ''}`,
103
+ value: { kind: 'copy', text: code },
104
+ };
105
+ });
106
+ }
107
+ function exitChord(state) {
108
+ if (state.exitArmed)
109
+ return reduce(state, { type: 'exit' });
110
+ return [{ ...state, exitArmed: true, rewindArmed: false }, [
111
+ { type: 'exit-timer', armed: true }, ...redraw(),
112
+ ]];
113
+ }
114
+ export function reduce(state, action) {
115
+ const head = state.decisions[0];
116
+ switch (action.type) {
117
+ case 'attachment-added': {
118
+ const replace = action.source !== undefined && action.text !== undefined && state.composer === action.source;
119
+ return [{
120
+ ...state, attachments: [...state.attachments, action.attachment],
121
+ ...(replace ? { composer: action.text } : {}),
122
+ }, [...(replace ? [{ type: 'set-composer', text: action.text }] : []), ...redraw()]];
123
+ }
124
+ case 'attachment-path': return [state, [{ type: 'attach', path: action.path, source: state.composer, text: state.composer }]];
125
+ case 'attachment-remove': {
126
+ if (action.index < 0 || action.index >= state.attachments.length)
127
+ return [state, []];
128
+ return [{ ...state, attachments: state.attachments.filter((_item, index) => index !== action.index) }, redraw()];
129
+ }
130
+ case 'composer-changed': {
131
+ if (head?.kind === 'question') {
132
+ if (head.custom === action.text)
133
+ return [state, []];
134
+ return [{ ...state, decisions: [{ ...head, custom: action.text }, ...state.decisions.slice(1)] }, redraw()];
135
+ }
136
+ if (state.search !== undefined) {
137
+ const found = matches(state.cells, action.text);
138
+ const cursor = found.length === 0 ? 0 : Math.min(state.search.cursor, found.length - 1);
139
+ const index = found[cursor];
140
+ return [{
141
+ ...state,
142
+ search: { ...state.search, cursor, matches: found, query: action.text },
143
+ ...(index === undefined ? {} : state.search.scope === 'history'
144
+ ? { overlay: { cursor: index, expanded: false, kind: 'history' } }
145
+ : { scrollOffset: Math.max(0, state.cells.length - 1 - index) }),
146
+ }, redraw()];
147
+ }
148
+ if (action.text === state.composer && !state.exitArmed && !state.rewindArmed)
149
+ return [state, []];
150
+ const next = {
151
+ ...state, composer: action.text, exitArmed: false, recall: undefined, rewindArmed: false,
152
+ };
153
+ if (action.completion === true)
154
+ return [next, [
155
+ { type: 'complete', caret: action.caret ?? action.text.length, query: action.text }, ...redraw(),
156
+ ]];
157
+ if (state.overlay?.kind === 'list' && state.overlay.purpose === 'completion') {
158
+ const { overlay: _overlay, ...withoutOverlay } = next;
159
+ return [withoutOverlay, redraw()];
160
+ }
161
+ return [next, redraw()];
162
+ }
163
+ case 'composer-set':
164
+ return [{ ...state, composer: action.text, exitArmed: false, rewindArmed: false }, [
165
+ { type: 'set-composer', text: action.text, ...(action.cursor === undefined ? {} : { cursor: action.cursor }) }, ...redraw(),
166
+ ]];
167
+ case 'clipboard-paste':
168
+ return head === undefined && state.overlay === undefined && state.search === undefined
169
+ ? [state, [{ type: 'clipboard-image' }]] : [state, []];
170
+ case 'ctrl-c': {
171
+ if (state.root?.status === 'running')
172
+ return [state, [{ type: 'interrupt' }]];
173
+ if (state.composer !== '' || state.attachments.length > 0) {
174
+ return [{
175
+ ...state, attachments: [], composer: '', exitArmed: false, recall: undefined, rewindArmed: false,
176
+ }, [{ type: 'set-composer', text: '' }, ...redraw()]];
177
+ }
178
+ return exitChord(state);
179
+ }
180
+ case 'ctrl-d':
181
+ if (state.composer !== '')
182
+ return [state, []];
183
+ return exitChord(state);
184
+ case 'decision-cancel': {
185
+ if (head === undefined)
186
+ return [state, []];
187
+ return head.kind === 'approval'
188
+ ? removeHead(state, { type: 'settle-decision', id: head.id, answer: 'cancelled' })
189
+ : removeHead(state, { type: 'reject-decision', id: head.id, reason: 'withdrawn' });
190
+ }
191
+ case 'decision-enqueued': {
192
+ if (state.decisions.some(decision => decision.id === action.decision.id))
193
+ return [state, []];
194
+ const decisions = [...state.decisions, action.decision];
195
+ const effects = [
196
+ ...(state.root?.status === 'running' ? [{ type: 'bell' }] : []),
197
+ ...(head === undefined ? [focusAfter(state, decisions), ...redraw()] : redraw()),
198
+ ];
199
+ const { overlay: _overlay, search: _search, ...withoutOverlay } = state;
200
+ return [{ ...withoutOverlay, decisions, exitArmed: false, rewindArmed: false }, effects];
201
+ }
202
+ case 'decision-move': {
203
+ if (head === undefined)
204
+ return [state, []];
205
+ const next = moveCursor(head, head.cursor + action.offset);
206
+ return [{ ...state, decisions: [next, ...state.decisions.slice(1)] }, redraw()];
207
+ }
208
+ case 'decision-number': {
209
+ if (head === undefined || action.number < 1)
210
+ return [state, []];
211
+ const next = moveCursor(head, action.number - 1);
212
+ return [{ ...state, decisions: [next, ...state.decisions.slice(1)] }, redraw()];
213
+ }
214
+ case 'decision-toggle': {
215
+ if (head?.kind !== 'question')
216
+ return [state, []];
217
+ const question = head.questions[head.index];
218
+ const option = question?.options[head.cursor];
219
+ if (question?.multiSelect !== true || option === undefined)
220
+ return [state, []];
221
+ const selected = head.selected.includes(option.label)
222
+ ? head.selected.filter(label => label !== option.label)
223
+ : [...head.selected, option.label];
224
+ return [{ ...state, decisions: [{ ...head, selected }, ...state.decisions.slice(1)] }, redraw()];
225
+ }
226
+ case 'decision-submit': {
227
+ if (head === undefined)
228
+ return [state, []];
229
+ if (head.kind === 'approval') {
230
+ const answer = head.cursor === 0 ? 'allowed-once' : head.cursor === 1 ? 'rejected' : 'cancelled';
231
+ return removeHead(state, { type: 'settle-decision', id: head.id, answer });
232
+ }
233
+ const question = head.questions[head.index];
234
+ if (question === undefined)
235
+ return [state, []];
236
+ const custom = question.allowCustom !== false && head.cursor === question.options.length ? question.secret === true ? head.custom : head.custom.trim() : undefined;
237
+ if (custom !== undefined && custom === '')
238
+ return [state, []];
239
+ const option = question.options[head.cursor];
240
+ const selected = question.multiSelect
241
+ ? head.selected.length === 0 && option !== undefined ? [option.label] : head.selected
242
+ : option === undefined ? [] : [option.label];
243
+ const answer = { id: question.id, selected, ...(custom === undefined ? {} : { custom }) };
244
+ const answers = [...head.answers, answer];
245
+ if (head.index + 1 < head.questions.length) {
246
+ const next = { ...head, answers, cursor: 0, custom: '', index: head.index + 1, selected: [] };
247
+ return [{ ...state, decisions: [next, ...state.decisions.slice(1)] }, [{ type: 'set-composer', text: '' }, ...redraw()]];
248
+ }
249
+ return removeHead(state, { type: 'settle-decision', id: head.id, answer: { answers } });
250
+ }
251
+ case 'decision-withdrawn': {
252
+ const index = state.decisions.findIndex(decision => decision.id === action.id);
253
+ if (index === -1)
254
+ return [state, []];
255
+ const decisions = state.decisions.filter(decision => decision.id !== action.id);
256
+ const effects = [{ type: 'reject-decision', id: action.id, reason: 'withdrawn' }];
257
+ if (index === 0)
258
+ effects.push(focusAfter(state, decisions), ...redraw());
259
+ return [{ ...state, decisions }, effects];
260
+ }
261
+ case 'disarm-exit':
262
+ return state.exitArmed || state.rewindArmed
263
+ ? [{ ...state, exitArmed: false, rewindArmed: false }, [
264
+ { type: 'exit-timer', armed: false }, ...redraw(),
265
+ ]] : [state, []];
266
+ case 'escape':
267
+ if (head !== undefined)
268
+ return reduce(state, { type: 'decision-cancel' });
269
+ if (state.search !== undefined)
270
+ return reduce(state, { type: 'search-close' });
271
+ if (state.overlay !== undefined)
272
+ return reduce(state, { type: 'overlay-close' });
273
+ if (state.root?.status === 'running') {
274
+ return [{ ...state, rewindArmed: false }, [{ type: 'interrupt' }]];
275
+ }
276
+ if (state.root !== undefined) {
277
+ if (state.rewindArmed) {
278
+ if (state.composer !== '')
279
+ return [{
280
+ ...state, attachments: [], composer: '', prompts: [...state.prompts, state.composer],
281
+ recall: undefined, rewindArmed: false,
282
+ }, [{ type: 'set-composer', text: '' }, ...redraw()]];
283
+ return [{ ...state, rewindArmed: false }, [
284
+ { type: 'activate-overlay', value: { kind: 'open-rewind' } }, ...redraw(),
285
+ ]];
286
+ }
287
+ return [{ ...state, rewindArmed: true }, redraw()];
288
+ }
289
+ return [state, []];
290
+ case 'exit': {
291
+ const effects = state.decisions.map(decision => ({ type: 'reject-decision', id: decision.id, reason: 'terminal' }));
292
+ return [{ ...state, decisions: [] }, [...effects, { type: 'exit' }]];
293
+ }
294
+ case 'external-edit':
295
+ return head === undefined && state.overlay === undefined && state.search === undefined
296
+ ? [state, [{ type: 'external-edit', cwd: state.root?.cwd ?? state.cwd, text: state.composer }]]
297
+ : [state, []];
298
+ case 'help':
299
+ return head === undefined
300
+ ? [{ ...state, overlay: help(), search: undefined, exitArmed: false, rewindArmed: false }, [
301
+ ...(state.search === undefined ? [] : [{ type: 'set-composer', text: state.composer }]),
302
+ ...redraw(),
303
+ ]]
304
+ : [state, []];
305
+ case 'history-copy': {
306
+ if (state.overlay?.kind !== 'history')
307
+ return [state, []];
308
+ const cell = state.cells[state.overlay.cursor];
309
+ return cell === undefined ? [state, []] : [state, [{ type: 'copy', text: copyText(cell) }]];
310
+ }
311
+ case 'history-move': {
312
+ if (state.overlay?.kind !== 'history' || state.cells.length === 0)
313
+ return [state, []];
314
+ const cursor = Math.max(0, Math.min(state.cells.length - 1, state.overlay.cursor + action.offset));
315
+ const load = cursor === 0 && action.offset < 0 && state.historyHasMore && state.root !== undefined
316
+ ? [{ type: 'load-history', rootId: state.root.id }] : [];
317
+ return [{ ...state, overlay: { ...state.overlay, cursor, expanded: false } }, [...load, ...redraw()]];
318
+ }
319
+ case 'history-page': {
320
+ if (state.root?.id !== action.rootId)
321
+ return [state, []];
322
+ const added = Math.max(0, action.cells.length - state.cells.length);
323
+ let overlay = state.overlay?.kind === 'history'
324
+ ? { ...state.overlay, cursor: Math.max(0, added - 1) }
325
+ : state.overlay;
326
+ const search = refreshSearch(state.search, action.cells);
327
+ if (search?.scope === 'history' && overlay?.kind === 'history') {
328
+ overlay = { ...overlay, cursor: search.matches[search.cursor] ?? overlay.cursor };
329
+ }
330
+ return [{
331
+ ...state, cells: action.cells, historyHasMore: action.hasMore,
332
+ scrollOffset: action.cells.length === 0 ? 0 : action.cells.length - 1,
333
+ ...(overlay === undefined ? {} : { overlay }),
334
+ ...(search === undefined ? {} : { search }),
335
+ }, redraw()];
336
+ }
337
+ case 'history-toggle':
338
+ return state.overlay?.kind !== 'history' ? [state, []] : [{
339
+ ...state, overlay: { ...state.overlay, expanded: !state.overlay.expanded },
340
+ }, redraw()];
341
+ case 'open-history':
342
+ return head !== undefined ? [state, []] : [{
343
+ ...state, overlay: {
344
+ cursor: Math.max(0, state.cells.length - 1), expanded: false, kind: 'history',
345
+ }, search: undefined, exitArmed: false, rewindArmed: false,
346
+ }, redraw()];
347
+ case 'open-details': {
348
+ if (head !== undefined || state.root === undefined)
349
+ return [state, []];
350
+ const count = (state.root.jobs?.length ?? 0) + (state.root.subagents?.length ?? 0);
351
+ return count === 0 ? [state, []] : [{
352
+ ...state, overlay: { cursor: 0, expanded: false, kind: 'details' },
353
+ search: undefined, exitArmed: false, rewindArmed: false,
354
+ }, redraw()];
355
+ }
356
+ case 'completion-ready': {
357
+ if (head !== undefined || state.composer !== action.query)
358
+ return [state, []];
359
+ if (action.options.length === 0) {
360
+ if (state.overlay?.kind !== 'list' || state.overlay.purpose !== 'completion')
361
+ return [state, []];
362
+ const { overlay: _overlay, ...withoutOverlay } = state;
363
+ return [withoutOverlay, redraw()];
364
+ }
365
+ return [{ ...state, overlay: {
366
+ cursor: 0, kind: 'list', options: action.options, purpose: 'completion', title: 'Complete',
367
+ } }, redraw()];
368
+ }
369
+ case 'open-overlay':
370
+ return head === undefined
371
+ ? [{ ...state, overlay: action.overlay, exitArmed: false, rewindArmed: false }, redraw()]
372
+ : [state, []];
373
+ case 'open-file': return [state, [{ type: 'activate-overlay', value: { kind: 'open-file', path: action.path } }]];
374
+ case 'overlay-close': {
375
+ if (state.overlay === undefined)
376
+ return [state, []];
377
+ const { overlay: _overlay, ...withoutOverlay } = state;
378
+ return [withoutOverlay, redraw()];
379
+ }
380
+ case 'overlay-move': {
381
+ if (state.overlay === undefined)
382
+ return [state, []];
383
+ if (state.overlay.kind === 'info') {
384
+ const current = state.overlay.scrollOffset ?? 0;
385
+ const scrollOffset = Math.max(0, Math.min(action.limit ?? current, current + action.offset));
386
+ return scrollOffset === current ? [state, []]
387
+ : [{ ...state, overlay: { ...state.overlay, scrollOffset } }, redraw()];
388
+ }
389
+ const count = (state.root?.jobs?.length ?? 0) + (state.root?.subagents?.length ?? 0);
390
+ const overlay = moveOverlay(state.overlay, state.overlay.cursor + action.offset, count);
391
+ return overlay === state.overlay ? [state, []] : [{ ...state, overlay }, redraw()];
392
+ }
393
+ case 'overlay-number': {
394
+ if (state.overlay === undefined || state.overlay.kind === 'info' || action.number < 1)
395
+ return [state, []];
396
+ const count = (state.root?.jobs?.length ?? 0) + (state.root?.subagents?.length ?? 0);
397
+ return [{ ...state, overlay: moveOverlay(state.overlay, action.number - 1, count) }, redraw()];
398
+ }
399
+ case 'overlay-submit': {
400
+ const overlay = state.overlay;
401
+ if (overlay === undefined)
402
+ return [state, []];
403
+ if (overlay.kind === 'history')
404
+ return reduce(state, { type: 'history-toggle' });
405
+ if (overlay.kind === 'info')
406
+ return reduce(state, { type: 'overlay-close' });
407
+ if (overlay.kind === 'details') {
408
+ const job = state.root?.jobs?.[overlay.cursor - (state.root?.subagents?.length ?? 0)];
409
+ if (job !== undefined)
410
+ return [state, [{ type: 'activate-overlay', value: { jobId: job.id, kind: 'job-output' } }]];
411
+ return [{ ...state, overlay: { ...overlay, expanded: !overlay.expanded } }, redraw()];
412
+ }
413
+ if (overlay.kind === 'confirm') {
414
+ if (overlay.cursor !== 0)
415
+ return reduce(state, { type: 'overlay-close' });
416
+ const { overlay: _overlay, ...withoutOverlay } = state;
417
+ return [withoutOverlay, [{ type: 'activate-overlay', value: overlay.value }, ...redraw()]];
418
+ }
419
+ const option = overlay.options[overlay.cursor];
420
+ if (option === undefined)
421
+ return [state, []];
422
+ if (option.value.kind === 'insert') {
423
+ const { overlay: _overlay, ...withoutOverlay } = state;
424
+ if (action.execute === true && option.submitOnEnter === true) {
425
+ return reduce({ ...withoutOverlay, composer: option.value.text.trimEnd() }, { type: 'submit' });
426
+ }
427
+ return [{ ...withoutOverlay, composer: option.value.text, recall: undefined }, [
428
+ { type: 'set-composer', text: option.value.text }, ...redraw(),
429
+ ]];
430
+ }
431
+ if (option.value.kind === 'attach') {
432
+ const { overlay: _overlay, ...withoutOverlay } = state;
433
+ return [withoutOverlay, [{
434
+ type: 'attach', path: option.value.path, source: option.value.source, text: option.value.text,
435
+ }, ...redraw()]];
436
+ }
437
+ if (option.value.kind === 'copy') {
438
+ const { overlay: _overlay, ...withoutOverlay } = state;
439
+ return [withoutOverlay, [{ type: 'copy', text: option.value.text }, ...redraw()]];
440
+ }
441
+ if (option.value.kind === 'permission' && option.danger === true) {
442
+ return [{ ...state, overlay: {
443
+ acceptLabel: 'Enable',
444
+ cursor: 1,
445
+ detail: option.confirmDetail ?? ['This preset disables approval prompts.'],
446
+ kind: 'confirm',
447
+ title: `Enable ${option.label}?`,
448
+ value: option.value,
449
+ } }, redraw()];
450
+ }
451
+ const { overlay: _overlay, ...withoutOverlay } = state;
452
+ return [withoutOverlay, [{ type: 'activate-overlay', value: option.value }, ...redraw()]];
453
+ }
454
+ case 'redraw': return [state, [{ type: 'redraw', force: true }]];
455
+ case 'prompt-recorded':
456
+ return state.root?.id !== action.rootId
457
+ ? [state, []] : [{ ...state, prompts: [...state.prompts, action.text], recall: undefined }, []];
458
+ case 'recall-move': {
459
+ if (state.prompts.length === 0)
460
+ return [state, []];
461
+ const current = state.recall?.cursor ?? state.prompts.length;
462
+ const cursor = Math.max(0, Math.min(state.prompts.length, current + action.offset));
463
+ const draft = state.recall?.draft ?? state.composer;
464
+ const text = cursor === state.prompts.length ? draft : state.prompts[cursor];
465
+ if (text === undefined)
466
+ return [state, []];
467
+ return [{
468
+ ...state, composer: text,
469
+ ...(cursor === state.prompts.length ? { recall: undefined } : { recall: { cursor, draft } }),
470
+ }, [{ type: 'set-composer', text }, ...redraw()]];
471
+ }
472
+ case 'request-completion':
473
+ return head === undefined && state.overlay === undefined && state.composer !== ''
474
+ ? [state, [{ type: 'complete', caret: action.caret, query: state.composer }]] : [state, []];
475
+ case 'request-search':
476
+ return head === undefined && state.overlay === undefined && state.root !== undefined
477
+ && state.composer.trim() !== ''
478
+ ? [state, [{ type: 'search', query: state.composer, rootId: state.root.id }]] : [state, []];
479
+ case 'scroll': {
480
+ if (state.cells.length === 0)
481
+ return [state, []];
482
+ const limit = state.cells.length - 1;
483
+ const scrollOffset = action.direction === 'end' ? 0
484
+ : action.direction === 'home' ? limit
485
+ : Math.max(0, Math.min(limit, state.scrollOffset
486
+ + (action.direction === 'page-up' ? 1 : -1) * (action.lines ?? PAGE_CELLS)));
487
+ const load = state.root !== undefined && state.historyHasMore
488
+ && (action.direction === 'home' || action.direction === 'page-up' && scrollOffset === limit)
489
+ ? [{ type: 'load-history', rootId: state.root.id }] : [];
490
+ return [{
491
+ ...state, scrollOffset,
492
+ ...(scrollOffset === 0 ? { newOutput: 0 } : {}),
493
+ }, [...load, ...redraw()]];
494
+ }
495
+ case 'search-close': {
496
+ if (state.search === undefined)
497
+ return [state, []];
498
+ const { search: _search, ...withoutSearch } = state;
499
+ return [withoutSearch, [{ type: 'set-composer', text: state.composer }, ...redraw()]];
500
+ }
501
+ case 'search-move': {
502
+ const search = state.search;
503
+ if (search === undefined || search.matches.length === 0)
504
+ return [state, []];
505
+ const cursor = (search.cursor + action.offset + search.matches.length) % search.matches.length;
506
+ const index = search.matches[cursor];
507
+ if (index === undefined)
508
+ return [state, []];
509
+ return [{
510
+ ...state, search: { ...search, cursor },
511
+ ...(search.scope === 'history'
512
+ ? { overlay: { cursor: index, expanded: false, kind: 'history' } }
513
+ : { scrollOffset: Math.max(0, state.cells.length - 1 - index) }),
514
+ }, redraw()];
515
+ }
516
+ case 'search-open': {
517
+ if (head !== undefined || action.scope === 'history' && state.overlay?.kind !== 'history')
518
+ return [state, []];
519
+ const found = matches(state.cells, '');
520
+ const next = { ...state, search: { cursor: 0, matches: found, query: '', scope: action.scope } };
521
+ if (action.scope === 'history')
522
+ return [next, [{ type: 'set-composer', text: '' }, ...redraw()]];
523
+ const { overlay: _overlay, ...withoutOverlay } = next;
524
+ return [withoutOverlay, [{ type: 'set-composer', text: '' }, ...redraw()]];
525
+ }
526
+ case 'suspend': return [state, [{ type: 'suspend' }]];
527
+ case 'stash-toggle': {
528
+ const current = { attachments: state.attachments, composer: state.composer, cursor: action.cursor };
529
+ if (state.stash === undefined) {
530
+ if (current.composer === '' && current.attachments.length === 0)
531
+ return [state, []];
532
+ return [{ ...state, attachments: [], composer: '', stash: current }, [
533
+ { type: 'set-composer', text: '' }, ...redraw(),
534
+ ]];
535
+ }
536
+ const restored = state.stash;
537
+ if (current.composer === '' && current.attachments.length === 0) {
538
+ const { stash: _stash, ...withoutStash } = state;
539
+ return [{ ...withoutStash, attachments: restored.attachments, composer: restored.composer }, [
540
+ { type: 'set-composer', text: restored.composer, cursor: restored.cursor }, ...redraw(),
541
+ ]];
542
+ }
543
+ return [{
544
+ ...state, attachments: restored.attachments, composer: restored.composer, stash: current,
545
+ }, [{ type: 'set-composer', text: restored.composer, cursor: restored.cursor }, ...redraw()]];
546
+ }
547
+ case 'terminal-lost':
548
+ return state.decisions.length === 0 ? [state, []] : [
549
+ { ...state, decisions: [] },
550
+ state.decisions.map(decision => ({ type: 'reject-decision', id: decision.id, reason: 'terminal' })),
551
+ ];
552
+ case 'cycle-permission':
553
+ if (head !== undefined)
554
+ return [state, []];
555
+ if (state.overlay === undefined)
556
+ return [state, [{ type: 'cycle-permission' }]];
557
+ {
558
+ const { overlay: _overlay, ...withoutOverlay } = state;
559
+ return [withoutOverlay, [{ type: 'cycle-permission' }]];
560
+ }
561
+ case 'toggle-send-mode': {
562
+ if (state.root?.status !== 'running')
563
+ return [state, []];
564
+ const sendMode = state.sendMode === 'steer' ? 'next-turn' : 'steer';
565
+ return [{ ...state, sendMode }, redraw()];
566
+ }
567
+ case 'toggle-tool-mode': {
568
+ const toolMode = state.toolMode === 'collapsed' ? 'expanded' : state.toolMode === 'expanded' ? 'hidden' : 'collapsed';
569
+ return [{ ...state, toolMode }, redraw()];
570
+ }
571
+ case 'turn-ended':
572
+ return state.root?.id === action.rootId && action.durationMs >= TURN_BELL_THRESHOLD_MS
573
+ ? [state, [{ type: 'bell' }]] : [state, []];
574
+ case 'submit': {
575
+ if (head !== undefined)
576
+ return reduce(state, { type: 'decision-submit' });
577
+ if (state.root === undefined || state.composer === '' && state.attachments.length === 0)
578
+ return [state, []];
579
+ if (state.root.status === 'running' && /^\/(?:btw|recap)(?:\s|$)/u.test(state.composer)) {
580
+ const cleared = { ...state, attachments: [], composer: '', exitArmed: false, recall: undefined, rewindArmed: false };
581
+ const [reported, effects] = reduce(cleared, {
582
+ type: 'runtime-error', message: 'finish or interrupt the turn first', rootId: state.root.id,
583
+ });
584
+ return [reported, [{ type: 'set-composer', text: '' }, ...effects]];
585
+ }
586
+ const mode = state.root.status === 'running' ? state.sendMode : 'next-turn';
587
+ return [{
588
+ ...state, attachments: [], composer: '', exitArmed: false, recall: undefined, rewindArmed: false,
589
+ }, [
590
+ { type: 'set-composer', text: '' }, {
591
+ type: 'submit', attachments: state.attachments, text: state.composer, mode,
592
+ }, ...redraw(),
593
+ ]];
594
+ }
595
+ case 'root-bound':
596
+ {
597
+ const { overlay: _overlay, ...withoutOverlay } = state;
598
+ return [{
599
+ ...withoutOverlay, cells: action.cells, historyHasMore: action.hasMore,
600
+ newOutput: 0, prompts: action.prompts, recall: undefined,
601
+ rewindArmed: false, root: action.root, scrollOffset: 0, search: undefined,
602
+ }, redraw()];
603
+ }
604
+ case 'root-cleared': {
605
+ if (state.root === undefined)
606
+ return [state, []];
607
+ const { root: _root, ...withoutRoot } = state;
608
+ return [{
609
+ ...withoutRoot, historyHasMore: false, newOutput: 0, prompts: [],
610
+ recall: undefined, rewindArmed: false, scrollOffset: 0, search: undefined,
611
+ }, redraw()];
612
+ }
613
+ case 'root-model':
614
+ if (state.root?.id !== action.rootId)
615
+ return [state, []];
616
+ {
617
+ const { effort: _effort, ...root } = state.root;
618
+ return [{ ...state, root: {
619
+ ...root,
620
+ model: action.model,
621
+ provider: action.provider,
622
+ ...(action.effort === undefined ? {} : { effort: action.effort }),
623
+ } }, redraw()];
624
+ }
625
+ case 'root-presentation': {
626
+ if (state.root?.id !== action.rootId)
627
+ return [state, []];
628
+ const { contextPercent: _contextPercent, ...root } = state.root;
629
+ const contextPercent = action.contextPercent === null
630
+ ? undefined
631
+ : action.contextPercent ?? state.root.contextPercent;
632
+ return [{ ...state, root: {
633
+ ...root,
634
+ ...(action.jobs === undefined && state.root.jobs === undefined ? {} : { jobs: action.jobs ?? state.root.jobs }),
635
+ ...(action.subagents === undefined && state.root.subagents === undefined
636
+ ? {} : { subagents: action.subagents ?? state.root.subagents }),
637
+ ...(contextPercent === undefined ? {} : { contextPercent }),
638
+ } }, redraw()];
639
+ }
640
+ case 'root-status':
641
+ return state.root?.id !== action.rootId || state.root.status === action.status
642
+ ? [state, []] : [{ ...state, root: { ...state.root, status: action.status } }, redraw()];
643
+ case 'root-permission':
644
+ return state.root?.id !== action.rootId || state.root.permission === action.preset
645
+ ? [state, []] : [{ ...state, root: { ...state.root, permission: action.preset } }, redraw()];
646
+ case 'root-title':
647
+ return state.root?.id !== action.rootId || state.root.title === action.title
648
+ ? [state, []] : [{ ...state, root: { ...state.root, title: action.title } }, redraw()];
649
+ case 'runtime-error':
650
+ if (action.rootId !== undefined && state.root?.id !== action.rootId)
651
+ return [state, []];
652
+ return [{
653
+ ...state, cells: [...state.cells, {
654
+ key: `runtime-error:${state.cells.length}`, kind: 'error', text: action.message,
655
+ }],
656
+ ...(state.scrollOffset === 0 ? {} : {
657
+ newOutput: state.newOutput + 1, scrollOffset: state.scrollOffset + 1,
658
+ }),
659
+ }, redraw()];
660
+ case 'transcript-changed': {
661
+ if (action.rootId !== undefined && state.root?.id !== action.rootId)
662
+ return [state, []];
663
+ const added = Math.max(0, action.cells.length - state.cells.length);
664
+ const search = refreshSearch(state.search, action.cells);
665
+ return [{
666
+ ...state, cells: action.cells,
667
+ ...(action.hasMore === undefined ? {} : { historyHasMore: action.hasMore }),
668
+ ...(search === undefined ? {} : { search }),
669
+ ...(state.scrollOffset === 0 ? { newOutput: 0 } : {
670
+ newOutput: state.newOutput + 1, scrollOffset: state.scrollOffset + added,
671
+ }),
672
+ }, redraw()];
673
+ }
674
+ case 'copy-assistant': {
675
+ const cell = completedAssistant(state.cells, action.selection === 'code' ? 1 : action.selection);
676
+ if (cell === undefined)
677
+ return [state, []];
678
+ if (action.selection !== 'code')
679
+ return [state, [{ type: 'copy', text: cell.text }]];
680
+ return [{ ...state, overlay: {
681
+ cursor: 0, kind: 'list', options: codeBlockOptions(cell.text), purpose: 'copy', title: 'Copy code block',
682
+ } }, redraw()];
683
+ }
684
+ }
685
+ }