@antst/dashi 0.1.0-alpha.5

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