@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
@@ -0,0 +1,414 @@
1
+ import { Editor, TuiAltScreen, TuiMainScreen, stripTerminalSequences, truncateToWidth, wrapTextWithAnsi, } from '@earendil-works/pi-tui';
2
+ import { caretOffset, installInput } from './input.js';
3
+ import { hasCompletionTrigger } from './completion-trigger.js';
4
+ import { createProcessTerminal } from './terminal-guard.js';
5
+ const RESET = '\u001B[0m';
6
+ const BOLD_CYAN = '\u001B[1;36m';
7
+ const DIM = '\u001B[2m';
8
+ const FAINT_BORDER = '\u001B[90m';
9
+ const GREEN = '\u001B[32m';
10
+ const RED = '\u001B[31m';
11
+ const YELLOW = '\u001B[33m';
12
+ const COPY_LIMIT = 64 * 1024;
13
+ const OVERSCAN_CELLS = 8;
14
+ const THEME_GENERATION = 1;
15
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
16
+ const SPINNER_INTERVAL_MS = 80;
17
+ export function supportsOsc52(terminalType) {
18
+ return terminalType?.toLocaleLowerCase() !== 'dumb';
19
+ }
20
+ function setEditor(editor, text, cursor) {
21
+ editor.setText(text);
22
+ if (cursor === undefined)
23
+ return;
24
+ const lines = text.split('\n');
25
+ const lineIndex = Math.max(0, Math.min(cursor.line, lines.length - 1));
26
+ const line = lines[lineIndex] ?? '';
27
+ const col = Math.max(0, Math.min(cursor.col, line.length));
28
+ const suffix = `${line.slice(col)}${lines.slice(lineIndex + 1).map(value => `\n${value}`).join('')}`;
29
+ const graphemes = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(suffix)].length;
30
+ for (let step = 0; step < graphemes; step++)
31
+ editor.handleInput('\u001B[D');
32
+ }
33
+ function toolText(cell, mode, accessible) {
34
+ const card = cell.tool;
35
+ const color = cell.kind === 'error' ? RED : cell.pending ? YELLOW : GREEN;
36
+ const marker = accessible
37
+ ? `[${cell.kind === 'error' ? 'error' : cell.pending === true ? 'pending' : 'done'}]`
38
+ : cell.pending === true ? '○' : '●';
39
+ if (card === undefined)
40
+ return `${color}${marker} ${plain(cell.text)}${RESET}`;
41
+ const status = card.status === undefined ? '' : ` ${DIM}${plain(card.status)}${RESET}`;
42
+ const summaries = card.diffs?.map(diff => `${plain(diff.path)} ${GREEN}+${String(diff.added)}${RESET} ${RED}-${String(diff.removed)}${RESET}`) ?? [];
43
+ const head = `${color}${marker} ${plain(card.title)}${RESET}${status}${summaries.length === 0 ? '' : ` ${summaries.join(' · ')}`}`;
44
+ if (mode !== 'expanded' || cell.collapsed === true)
45
+ return head;
46
+ const body = card.diffs === undefined
47
+ ? card.body
48
+ : card.diffs.flatMap(diff => [`${FAINT_BORDER}── ${plain(diff.path)}${RESET}`, ...diff.lines.map(value => plain(value))]).join('\n');
49
+ return body === undefined || body === '' ? head : `${head}\n${DIM}${plain(body, true)}${RESET}`;
50
+ }
51
+ function cellText(cell, toolMode, accessible, now = Date.now()) {
52
+ if (cell.tool !== undefined || cell.kind === 'tool')
53
+ return toolText(cell, toolMode, accessible);
54
+ const raw = accessible && cell.kind === 'todo'
55
+ ? cell.text.replace(/^✓ /gmu, '[done] ').replace(/^● /gmu, '[active] ').replace(/^○ /gmu, '[pending] ')
56
+ : cell.text;
57
+ const safe = plain(raw, true);
58
+ switch (cell.kind) {
59
+ case 'user': return `${BOLD_CYAN}You${RESET}\n${safe}`;
60
+ case 'assistant': return `${BOLD_CYAN}Assistant${RESET}\n${safe}`;
61
+ case 'command': {
62
+ const elapsed = cell.elapsedMs ?? (cell.startedAt === undefined ? undefined : Math.max(0, now - cell.startedAt));
63
+ const marker = cell.pending === true
64
+ ? accessible ? '[running]' : SPINNER_FRAMES[Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length]
65
+ : accessible ? '[done]' : '◆';
66
+ return `${cell.pending === true ? YELLOW : DIM}${marker} ${safe}${elapsed === undefined ? '' : ` · ${duration(elapsed)}`}${RESET}`;
67
+ }
68
+ case 'context': return `${DIM}Context · ${safe}${RESET}`;
69
+ case 'shell': return `${DIM}Shell${RESET}\n${safe}`;
70
+ case 'compaction': return `${DIM}${accessible ? '[compacted]' : '↻'} ${safe}${RESET}`;
71
+ case 'reasoning': return cell.collapsed
72
+ ? `${DIM}Thinking · ${[...safe].length} characters${RESET}`
73
+ : `${DIM}Thinking${RESET}\n${safe}`;
74
+ case 'outcome': return `${DIM}${accessible ? '[done]' : '✓'} ${safe}${RESET}`;
75
+ case 'todo': return cell.collapsed ? `${DIM}${safe}${RESET}` : `${BOLD_CYAN}${safe}${RESET}`;
76
+ case 'error': return `${RED}Error · ${safe}${RESET}${cell.detail === undefined ? '' : `\n${DIM}${plain(cell.detail)}${RESET}`}`;
77
+ }
78
+ }
79
+ function duration(ms) {
80
+ const seconds = Math.max(0, Math.floor(ms / 1_000));
81
+ if (seconds < 60)
82
+ return `${String(seconds)}s`;
83
+ const minutes = Math.floor(seconds / 60);
84
+ if (minutes < 60)
85
+ return `${String(minutes)}m ${String(seconds % 60)}s`;
86
+ return `${String(Math.floor(minutes / 60))}h ${String(minutes % 60)}m`;
87
+ }
88
+ function jobElapsed(job, now) {
89
+ return Math.max(0, (job.finishedAt ?? now) - job.startedAt);
90
+ }
91
+ function subagentElapsed(subagent, now) {
92
+ if (subagent.elapsedMs === undefined)
93
+ return undefined;
94
+ if (subagent.active === undefined)
95
+ return subagent.elapsedMs;
96
+ const through = subagent.state === 'running' ? now : subagent.active.through;
97
+ return subagent.elapsedMs + Math.max(0, through - subagent.active.since);
98
+ }
99
+ function activities(state) {
100
+ return [
101
+ ...(state.root?.subagents ?? []).map(value => ({ kind: 'subagent', value })),
102
+ ...(state.root?.jobs ?? []).map(value => ({ kind: 'job', value })),
103
+ ];
104
+ }
105
+ function latestPendingCommand(cells) {
106
+ for (let index = cells.length - 1; index >= 0; index--) {
107
+ const cell = cells[index];
108
+ if (cell?.kind === 'command' && cell.pending === true)
109
+ return cell;
110
+ }
111
+ }
112
+ function activityText(activity, now) {
113
+ if (activity.kind === 'job') {
114
+ const job = activity.value;
115
+ return `Job · ${job.label} · ${job.status} · ${duration(jobElapsed(job, now))}${job.detail === undefined ? '' : ` · ${job.detail}`}`;
116
+ }
117
+ const subagent = activity.value;
118
+ const elapsed = subagentElapsed(subagent, now);
119
+ return `Subagent · ${subagent.label} · ${subagent.state}${elapsed === undefined ? '' : ` · ${duration(elapsed)}`}${subagent.summary === undefined ? '' : ` · ${subagent.summary}`}`;
120
+ }
121
+ function plain(text, preserveNewlines = false) {
122
+ const withoutC1 = text
123
+ .replace(/\u009B[0-?]*[ -/]*[@-~]/gu, '')
124
+ .replace(/[\u0080-\u009F]/gu, '');
125
+ return [...stripTerminalSequences(withoutC1)].map(character => {
126
+ const code = character.codePointAt(0) ?? 0;
127
+ if (preserveNewlines && character === '\n')
128
+ return character;
129
+ return code < 32 || code === 127 ? ' ' : character;
130
+ }).join('');
131
+ }
132
+ function line(text, width) {
133
+ return truncateToWidth(text, Math.max(1, width), '…');
134
+ }
135
+ function theme() {
136
+ const identity = (text) => text;
137
+ return {
138
+ borderColor: text => `${FAINT_BORDER}${text}${RESET}`,
139
+ selectList: {
140
+ selectedPrefix: identity,
141
+ selectedText: identity,
142
+ description: identity,
143
+ scrollInfo: identity,
144
+ noMatch: identity,
145
+ },
146
+ };
147
+ }
148
+ export function createRenderer(options) {
149
+ const terminal = options.terminal ?? createProcessTerminal(options.inline ? undefined : lines => {
150
+ options.dispatch({ type: 'scroll', direction: lines > 0 ? 'page-up' : 'page-down', lines: Math.abs(lines) });
151
+ });
152
+ const terminalType = options.terminalType ?? (options.terminal === undefined ? process.env.TERM : undefined);
153
+ const accessible = options.accessible === true || terminalType?.toLocaleLowerCase() === 'dumb';
154
+ const osc52 = supportsOsc52(terminalType);
155
+ const tui = options.inline
156
+ ? new TuiMainScreen(terminal, true)
157
+ : new TuiAltScreen(terminal, true, undefined, { mouse: true });
158
+ const editor = new Editor(tui, theme(), { paddingX: 1 });
159
+ editor.disableSubmit = true;
160
+ editor.onChange = text => {
161
+ const caret = caretOffset(text, editor.getCursor());
162
+ options.dispatch({
163
+ type: 'composer-changed', text, caret, completion: hasCompletionTrigger(text, caret),
164
+ });
165
+ };
166
+ const lineCache = new Map();
167
+ let animationTimer;
168
+ const cellLines = (cell, width, toolMode, now) => {
169
+ const key = `${THEME_GENERATION}:${accessible}:${width}:${toolMode}:${cell.key}:${cell.kind}:${cell.pending === true}:${cell.collapsed === true}:${cell.startedAt ?? ''}:${cell.elapsedMs ?? ''}:${cell.text}:${cell.detail ?? ''}:${JSON.stringify(cell.tool)}`;
170
+ const cached = cell.pending === true ? undefined : lineCache.get(key);
171
+ if (cached !== undefined)
172
+ return cached;
173
+ const rendered = wrapTextWithAnsi(cellText(cell, toolMode, accessible, now), Math.max(1, width - 2)).map(value => ` ${value}`);
174
+ if (cell.pending !== true) {
175
+ lineCache.set(key, rendered);
176
+ if (lineCache.size > 512)
177
+ lineCache.delete(lineCache.keys().next().value);
178
+ }
179
+ return rendered;
180
+ };
181
+ const optionLine = (label, number, cursor, selected) => `${cursor ? BOLD_CYAN : DIM}${cursor ? accessible ? '>' : '›' : ' '} ${String(number)} ${selected ? '[x]' : '[ ]'} ${plain(label)}${RESET}`;
182
+ const announcement = (title, count, selected, cursor = 0) => {
183
+ if (!accessible)
184
+ return title;
185
+ const items = `${String(count)} item${count === 1 ? '' : 's'}`;
186
+ return selected === undefined ? `${title} · ${items}`
187
+ : `${title} · ${items} · selected ${String(cursor + 1)}: ${selected}`;
188
+ };
189
+ const decisionPanel = (decision, width, rows) => {
190
+ if (decision.kind === 'approval') {
191
+ const choices = ['Allow once', 'Reject', 'Cancel'];
192
+ const heading = announcement(`Approval · ${plain(decision.toolName)}`, choices.length, choices[decision.cursor], decision.cursor);
193
+ const prompt = wrapTextWithAnsi(`${BOLD_CYAN}${heading}${RESET}\n${plain(decision.prompt, true)}`, width);
194
+ return [...prompt, ...choices.map((choice, index) => optionLine(choice, index + 1, decision.cursor === index, false))];
195
+ }
196
+ const question = decision.questions[decision.index];
197
+ if (question === undefined)
198
+ return [`${RED}Invalid empty question batch${RESET}`];
199
+ const planReview = question.intent?.kind === 'plan-review';
200
+ const heading = planReview ? 'Plan review' : question.header ?? `Question ${String(decision.index + 1)}/${String(decision.questions.length)}`;
201
+ const selected = question.options[decision.cursor]?.label ?? 'Other (type an answer)';
202
+ const announced = announcement(plain(heading), question.options.length + 1, selected, decision.cursor);
203
+ const prompt = wrapTextWithAnsi(`${BOLD_CYAN}${announced}${RESET}\n${plain(question.question, true)}`, width);
204
+ const choiceRows = question.options.map((option, index) => optionLine(option.description === undefined ? option.label : `${option.label} — ${option.description}`, index + 1, decision.cursor === index, decision.selected.includes(option.label)));
205
+ choiceRows.push(optionLine('Other (type an answer)', question.options.length + 1, decision.cursor === question.options.length, false));
206
+ const detailLimit = planReview ? Math.max(3, rows - prompt.length - choiceRows.length - 5) : 3;
207
+ const detail = question.detail === undefined ? [] : wrapTextWithAnsi(`${DIM}${plain(question.detail, true)}${RESET}`, width).slice(0, detailLimit);
208
+ const custom = decision.cursor === question.options.length ? editor.render(width) : [];
209
+ const rendered = [...prompt, ...detail, ...choiceRows, ...custom];
210
+ return rows < 12 ? rendered.slice(0, Math.max(2, rows - 4)) : rendered;
211
+ };
212
+ const historyPanel = (overlay, state, width, rows) => {
213
+ const selected = state.cells[overlay.cursor];
214
+ const radius = Math.max(1, Math.min(4, Math.floor((rows - 6) / 2)));
215
+ const start = Math.max(0, overlay.cursor - radius);
216
+ const end = Math.min(state.cells.length, overlay.cursor + radius + 1);
217
+ const selectedSummary = selected === undefined ? undefined
218
+ : `${selected.kind} · ${plain(selected.tool?.title ?? selected.text).split('\n')[0] ?? ''}`;
219
+ const result = [`${BOLD_CYAN}${announcement('History', state.cells.length, selectedSummary, overlay.cursor)}${RESET}`];
220
+ for (let index = start; index < end; index++) {
221
+ const cell = state.cells[index];
222
+ if (cell === undefined)
223
+ continue;
224
+ const summary = plain(cell.tool?.title ?? cell.text).split('\n')[0] ?? '';
225
+ result.push(line(`${index === overlay.cursor ? accessible ? '>' : '›' : ' '} ${cell.kind} · ${summary}`, width));
226
+ }
227
+ if (overlay.expanded && selected !== undefined) {
228
+ result.push('', ...wrapTextWithAnsi(cellText(selected, 'expanded', accessible), width).slice(0, Math.max(2, rows - result.length - 4)));
229
+ }
230
+ result.push(`${DIM}↑↓ select · Enter expand · / search · y copy · Esc close${RESET}`);
231
+ return result;
232
+ };
233
+ const detailsPanel = (overlay, state, width, rows) => {
234
+ const items = activities(state);
235
+ const cursor = Math.min(overlay.cursor, Math.max(0, items.length - 1));
236
+ const radius = Math.max(1, Math.floor((rows - 7) / 2));
237
+ const start = Math.max(0, cursor - radius);
238
+ const end = Math.min(items.length, cursor + radius + 1);
239
+ const now = Date.now();
240
+ const selectedItem = items[cursor];
241
+ const result = [`${BOLD_CYAN}${announcement('Activity', items.length, selectedItem === undefined ? undefined : activityText(selectedItem, now), cursor)}${RESET}`];
242
+ for (let index = start; index < end; index++) {
243
+ const item = items[index];
244
+ if (item !== undefined)
245
+ result.push(line(`${index === cursor ? accessible ? '>' : '›' : ' '} ${activityText(item, now)}`, width));
246
+ }
247
+ const selected = items[cursor];
248
+ if (overlay.expanded && selected !== undefined) {
249
+ const detail = selected.kind === 'job'
250
+ ? [selected.value.id, `kind: ${selected.value.kind}`, `state: ${selected.value.status}`,
251
+ ...(selected.value.detail === undefined ? [] : [selected.value.detail])]
252
+ : [selected.value.id, `mode: ${selected.value.mode}`, `state: ${selected.value.state}`,
253
+ ...(selected.value.tokens === undefined ? [] : [`tokens: ${String(selected.value.tokens)}`]),
254
+ ...(selected.value.summary === undefined ? [] : [selected.value.summary])];
255
+ result.push('', ...detail.flatMap(value => wrapTextWithAnsi(`${DIM}${plain(value)}${RESET}`, width)));
256
+ }
257
+ result.push(`${DIM}↑↓ select · Enter details · Esc close${RESET}`);
258
+ return result.slice(0, Math.max(2, rows - 3));
259
+ };
260
+ const overlayPanel = (overlay, state, width, rows) => {
261
+ if (overlay.kind === 'history')
262
+ return historyPanel(overlay, state, width, rows);
263
+ if (overlay.kind === 'details')
264
+ return detailsPanel(overlay, state, width, rows);
265
+ if (overlay.kind === 'info') {
266
+ return [
267
+ `${BOLD_CYAN}${announcement(plain(overlay.title), overlay.lines.length)}${RESET}`,
268
+ ...overlay.lines.flatMap(value => wrapTextWithAnsi(plain(value, true), width)),
269
+ `${DIM}Enter or Esc to close${RESET}`,
270
+ ].slice(0, Math.max(2, rows - 3));
271
+ }
272
+ if (overlay.kind === 'confirm') {
273
+ const choices = [overlay.acceptLabel, 'Cancel'];
274
+ return [
275
+ `${YELLOW}${announcement(plain(overlay.title), choices.length, choices[overlay.cursor], overlay.cursor)}${RESET}`,
276
+ ...overlay.detail.flatMap(value => wrapTextWithAnsi(plain(value, true), width)),
277
+ optionLine(overlay.acceptLabel, 1, overlay.cursor === 0, false),
278
+ optionLine('Cancel', 2, overlay.cursor === 1, false),
279
+ ].slice(0, Math.max(2, rows - 3));
280
+ }
281
+ const selected = overlay.options[overlay.cursor];
282
+ const result = [
283
+ line(`${BOLD_CYAN}${announcement(plain(overlay.title), overlay.options.length, selected === undefined ? undefined : plain(selected.label), overlay.cursor)}${RESET}`, width),
284
+ ...(overlay.notice === undefined ? [] : [`${DIM}${plain(overlay.notice)}${RESET}`]),
285
+ ];
286
+ let group;
287
+ for (const [index, option] of overlay.options.entries()) {
288
+ if (option.group !== undefined && option.group !== group) {
289
+ group = option.group;
290
+ result.push(`${DIM}${plain(group)}${RESET}`);
291
+ }
292
+ const detail = option.detail === undefined ? '' : ` — ${plain(option.detail)}`;
293
+ const active = option.active === true ? accessible ? ' [active]' : ' ●' : '';
294
+ const danger = option.danger === true ? ` ${YELLOW}${accessible ? '[danger]' : '!'}${RESET}` : '';
295
+ result.push(line(`${index === overlay.cursor ? BOLD_CYAN : DIM}${index === overlay.cursor ? accessible ? '>' : '›' : ' '} ${String(index + 1)} ${plain(option.label)}${active}${detail}${RESET}${danger}`, width));
296
+ }
297
+ return result.slice(0, Math.max(2, rows - (overlay.purpose === 'completion' ? 6 : 3)));
298
+ };
299
+ const document = {
300
+ invalidate() { editor.invalidate(); },
301
+ render(width) {
302
+ const state = options.readState();
303
+ const now = Date.now();
304
+ const pendingCommand = latestPendingCommand(state.cells);
305
+ if (pendingCommand !== undefined && animationTimer === undefined) {
306
+ animationTimer = setTimeout(() => {
307
+ animationTimer = undefined;
308
+ tui.requestRender();
309
+ }, accessible ? 1_000 : SPINNER_INTERVAL_MS);
310
+ }
311
+ else if (pendingCommand === undefined && animationTimer !== undefined) {
312
+ clearTimeout(animationTimer);
313
+ animationTimer = undefined;
314
+ }
315
+ const rows = Math.max(1, terminal.rows);
316
+ if (width < 32 || rows < 8)
317
+ return [line('dashi — enlarge terminal to continue', width)];
318
+ const root = state.root;
319
+ const cwd = plain(root?.cwd ?? state.cwd);
320
+ const title = root === undefined
321
+ ? `${BOLD_CYAN}dashi${RESET} ${DIM}no session${RESET}`
322
+ : `${BOLD_CYAN}dashi${RESET} ${plain(root.title ?? root.id)}`;
323
+ const identity = root === undefined ? cwd : `${plain(root.id)} ${DIM}${cwd}${RESET}`;
324
+ const header = width >= 100
325
+ ? [line(`${title} ${DIM}${identity}${RESET}`, width)]
326
+ : [line(title, width), line(`${DIM}${identity}${RESET}`, width)];
327
+ const baseStatusText = state.exitArmed
328
+ ? `${DIM}Ctrl+C or Ctrl+D again to exit${RESET}`
329
+ : state.decisions.length > 0
330
+ ? `${DIM}decision 1/${String(state.decisions.length)} · ↑↓/1-9 choose · Enter answer · Esc cancel${RESET}`
331
+ : state.search !== undefined
332
+ ? `${DIM}${state.search.scope} search · Enter next · Shift+Enter previous · Esc close${RESET}`
333
+ : state.overlay !== undefined
334
+ ? `${DIM}${plain(state.overlay.kind === 'list' ? state.overlay.purpose : state.overlay.kind === 'history'
335
+ ? 'history' : state.overlay.kind === 'details' ? 'activity' : state.overlay.title)} · ↑↓ choose · Enter select · Esc close${RESET}`
336
+ : pendingCommand !== undefined
337
+ ? `${DIM}running ${plain(pendingCommand.text).split(/\s/u)[0] ?? 'command'}${pendingCommand.startedAt === undefined ? '' : ` · ${duration(now - pendingCommand.startedAt)}`}${RESET}`
338
+ : root === undefined
339
+ ? `${DIM}no session · cards ${state.toolMode} · Ctrl+J newline · Ctrl+L redraw${RESET}`
340
+ : root.status === 'running'
341
+ ? `${DIM}running · ${plain(root.model)} · Enter ${state.sendMode === 'steer' ? 'steer' : 'queue next turn'} · Ctrl+T toggle · cards ${state.toolMode} · Esc interrupt${RESET}`
342
+ : `${DIM}idle · ${plain(root.model)} · Enter send · cards ${state.toolMode}${RESET}`;
343
+ const activityCount = activities(state).length;
344
+ const baseStatus = `${baseStatusText}${root?.contextPercent === undefined ? '' : ` · ${String(root.contextPercent)}% context`}${activityCount === 0 ? '' : ' · Ctrl+B activity'}`;
345
+ const status = state.newOutput === 0 ? baseStatus
346
+ : `${YELLOW}${accessible ? '[new]' : '↓'} ${String(state.newOutput)} new output${state.newOutput === 1 ? '' : 's'} · Ctrl+End${RESET} · ${baseStatus}`;
347
+ const decision = state.decisions[0];
348
+ const attachmentRows = state.attachments.map((attachment, index) => line(`${YELLOW}[image ${String(index + 1)}]${RESET} ${plain(attachment.name)} · ${attachment.mediaType} · ${String(attachment.bytes)} B · ${plain(attachment.path)}`, width));
349
+ const activityRows = decision !== undefined || state.search !== undefined || state.overlay !== undefined
350
+ ? [] : activities(state).map(item => line(`${DIM}${activityText(item, now)}${RESET}`, width));
351
+ const control = decision !== undefined
352
+ ? decisionPanel(decision, width, rows)
353
+ : state.search !== undefined
354
+ ? [
355
+ ...(state.search.scope === 'history' && state.overlay?.kind === 'history'
356
+ ? historyPanel(state.overlay, state, width, rows).slice(0, Math.max(2, rows - 8)) : []),
357
+ `${BOLD_CYAN}${announcement(`Search ${state.search.scope}`, state.search.matches.length, state.search.matches.length === 0 ? undefined : `match ${String(state.search.cursor + 1)}`, state.search.cursor)}${RESET}`,
358
+ ...editor.render(width),
359
+ `${DIM}${state.search.matches.length === 0 ? 'no matches' : `${String(state.search.cursor + 1)}/${String(state.search.matches.length)}`} · Enter next · Shift+Enter previous${RESET}`,
360
+ ]
361
+ : state.overlay === undefined
362
+ ? [...activityRows, ...attachmentRows, ...editor.render(width)]
363
+ : [
364
+ ...overlayPanel(state.overlay, state, width, rows),
365
+ ...(state.overlay.kind === 'list' && state.overlay.purpose === 'completion'
366
+ ? [...attachmentRows, ...editor.render(width)]
367
+ : []),
368
+ ];
369
+ const fixedRows = header.length + control.length + 2;
370
+ const filtered = state.cells
371
+ .filter(cell => !(state.toolMode === 'hidden' && (cell.tool !== undefined || cell.kind === 'tool')));
372
+ const available = Math.max(0, rows - fixedRows);
373
+ const end = Math.max(0, filtered.length - Math.min(state.scrollOffset, Math.max(0, filtered.length - 1)));
374
+ const start = Math.max(0, end - available - OVERSCAN_CELLS);
375
+ const materialized = options.inline ? filtered : filtered.slice(start, end);
376
+ materializedCount = materialized.length;
377
+ const transcript = materialized.flatMap(cell => [...cellLines(cell, width, state.toolMode, now), '']);
378
+ const visible = options.inline ? transcript : transcript.slice(-available);
379
+ const spacerRows = options.inline ? 0 : Math.max(0, available - visible.length);
380
+ return [...header, '', ...Array.from({ length: spacerRows }, () => ''), ...visible, ...control, line(status, width)];
381
+ },
382
+ };
383
+ let materializedCount = 0;
384
+ tui.addChild(document);
385
+ tui.setFocus(editor);
386
+ installInput(tui, {
387
+ ...options,
388
+ insertNewline: () => { editor.insertTextAtCursor('\n'); },
389
+ readComposerCursor: () => editor.getCursor(),
390
+ });
391
+ return {
392
+ bell: () => { terminal.write('\u0007'); },
393
+ copy(text) {
394
+ if (!osc52)
395
+ return 'unsupported';
396
+ const safe = plain(text, true);
397
+ if (Buffer.byteLength(safe, 'utf8') > COPY_LIMIT)
398
+ return 'too-large';
399
+ terminal.write(`\u001B]52;c;${Buffer.from(safe).toString('base64')}\u0007`);
400
+ return 'ok';
401
+ },
402
+ drainInput: () => terminal.drainInput(),
403
+ materializedCells: () => materializedCount,
404
+ render: force => { tui.renderNow(force); },
405
+ setComposer: (text, cursor) => { setEditor(editor, text, cursor); },
406
+ start: () => { tui.start(); },
407
+ stop: preserveScreen => {
408
+ if (animationTimer !== undefined)
409
+ clearTimeout(animationTimer);
410
+ animationTimer = undefined;
411
+ tui.stop({ preserveScreen });
412
+ },
413
+ };
414
+ }
@@ -0,0 +1,18 @@
1
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
2
+ import type { Overlay, OverlayValue } from './state.js';
3
+ export interface RewindBoundary {
4
+ readonly atSeq?: number;
5
+ readonly label: string;
6
+ readonly prompt: string;
7
+ }
8
+ /** Return the text of a direct human message, excluding injected context. */
9
+ export declare function humanPrompt(event: SessionEvent): string | undefined;
10
+ export declare function humanPrompts(events: readonly SessionEvent[]): readonly string[];
11
+ /** Derive each human prompt and its controller-valid containing-turn cut. */
12
+ export declare function rewindBoundaries(events: readonly SessionEvent[]): readonly RewindBoundary[];
13
+ export declare function rewindOverlay(events: readonly SessionEvent[], roller: boolean): Overlay;
14
+ export declare function rewindActionOverlay(boundary: Extract<OverlayValue, {
15
+ kind: 'rewind-boundary';
16
+ }>): Overlay;
17
+ /** Read a fork's durable source boundary without storing lineage separately. */
18
+ export declare function inheritedTurn(events: readonly SessionEvent[], inheritedCount: number): number | undefined;
package/lib/rewind.js ADDED
@@ -0,0 +1,84 @@
1
+ /** Return the text of a direct human message, excluding injected context. */
2
+ export function humanPrompt(event) {
3
+ if (event.type !== 'user/message' || event.data.source.kind !== 'user')
4
+ return undefined;
5
+ const text = event.data.content
6
+ .filter(block => block.type === 'text')
7
+ .map(block => block.text).join('');
8
+ return text === '' ? undefined : text;
9
+ }
10
+ export function humanPrompts(events) {
11
+ return events.flatMap(event => humanPrompt(event) ?? []);
12
+ }
13
+ /** Derive each human prompt and its controller-valid containing-turn cut. */
14
+ export function rewindBoundaries(events) {
15
+ const boundaries = [];
16
+ let previousEnd;
17
+ let openTurn;
18
+ let prompted = false;
19
+ for (const event of events) {
20
+ if (event.type === 'turn/start') {
21
+ openTurn = event.data.turn;
22
+ prompted = false;
23
+ }
24
+ else if (event.type === 'user/message' && openTurn !== undefined) {
25
+ const prompt = humanPrompt(event);
26
+ if (prompt !== undefined) {
27
+ boundaries.push({
28
+ ...(previousEnd === undefined ? {} : { atSeq: previousEnd.seq }),
29
+ label: `${prompt.replaceAll(/\s+/gu, ' ')}${prompted ? ' · mid-turn' : ''}`,
30
+ prompt,
31
+ });
32
+ prompted = true;
33
+ }
34
+ }
35
+ else if (event.type === 'turn/end') {
36
+ previousEnd = event;
37
+ openTurn = undefined;
38
+ prompted = false;
39
+ }
40
+ }
41
+ return boundaries;
42
+ }
43
+ export function rewindOverlay(events, roller) {
44
+ const boundaries = rewindBoundaries(events);
45
+ return {
46
+ cursor: Math.max(0, boundaries.length - 1),
47
+ kind: 'list',
48
+ options: boundaries.map(boundary => ({
49
+ label: boundary.label,
50
+ value: {
51
+ kind: 'rewind-boundary', prompt: boundary.prompt, roller,
52
+ ...(boundary.atSeq === undefined ? {} : { atSeq: boundary.atSeq }),
53
+ },
54
+ })),
55
+ purpose: 'rewind',
56
+ title: 'Rewind to a prompt',
57
+ };
58
+ }
59
+ export function rewindActionOverlay(boundary) {
60
+ const base = {
61
+ prompt: boundary.prompt,
62
+ ...(boundary.atSeq === undefined ? {} : { atSeq: boundary.atSeq }),
63
+ };
64
+ const options = [
65
+ ...(boundary.roller ? [{
66
+ label: 'Restore code and conversation', value: { ...base, kind: 'rewind', mode: 'both' },
67
+ }] : []),
68
+ { label: 'Restore conversation', value: { ...base, kind: 'rewind', mode: 'conversation' } },
69
+ ...(boundary.roller ? [{
70
+ label: 'Restore code', value: { ...base, kind: 'rewind', mode: 'code' },
71
+ }] : []),
72
+ { label: 'Never mind', value: { kind: 'open-rewind' } },
73
+ ];
74
+ return { cursor: 0, kind: 'list', options, purpose: 'rewind-action', title: boundary.prompt };
75
+ }
76
+ /** Read a fork's durable source boundary without storing lineage separately. */
77
+ export function inheritedTurn(events, inheritedCount) {
78
+ for (let index = Math.min(inheritedCount, events.length) - 1; index >= 0; index--) {
79
+ const event = events[index];
80
+ if (event?.type === 'turn/end')
81
+ return event.data.turn;
82
+ }
83
+ return undefined;
84
+ }
@@ -0,0 +1,3 @@
1
+ import type { TerminalCell } from './state.js';
2
+ /** Format the existing terminal projection as a portable Markdown transcript. */
3
+ export declare function markdownTranscript(sessionId: string, cwd: string, title: string | undefined, cells: readonly TerminalCell[]): string;
@@ -0,0 +1,27 @@
1
+ function heading(kind) {
2
+ return kind === 'user' ? 'User'
3
+ : kind === 'assistant' ? 'Assistant'
4
+ : kind === 'reasoning' ? 'Reasoning'
5
+ : kind[0]?.toUpperCase() + kind.slice(1);
6
+ }
7
+ function body(cell) {
8
+ const parts = [cell.text];
9
+ if (cell.detail !== undefined && cell.detail !== cell.text)
10
+ parts.push(cell.detail);
11
+ if (cell.tool?.body !== undefined)
12
+ parts.push(cell.tool.body);
13
+ for (const diff of cell.tool?.diffs ?? []) {
14
+ parts.push(`${diff.path} (+${String(diff.added)} -${String(diff.removed)})`, ...diff.lines);
15
+ }
16
+ return parts.filter(Boolean).join('\n\n');
17
+ }
18
+ /** Format the existing terminal projection as a portable Markdown transcript. */
19
+ export function markdownTranscript(sessionId, cwd, title, cells) {
20
+ const sections = cells.filter(cell => cell.pending !== true).map(cell => `## ${heading(cell.kind)}\n\n${body(cell)}`);
21
+ return [
22
+ `# ${title ?? 'Untitled DSH session'}`,
23
+ `Session: \`${sessionId}\` `,
24
+ `Working directory: \`${cwd}\``,
25
+ ...sections,
26
+ ].join('\n\n').trimEnd() + '\n';
27
+ }
@@ -0,0 +1,15 @@
1
+ import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller';
2
+ export interface SessionListOptions {
3
+ readonly all: boolean;
4
+ readonly cwd?: string;
5
+ readonly json: boolean;
6
+ }
7
+ /** Parse only the profile-provided `sessions list` grammar. */
8
+ export declare function parseSessionListArgs(args: readonly string[]): SessionListOptions | undefined;
9
+ export declare function sessionTitle(item: SessionSummary): string | undefined;
10
+ export declare function isSessionId(value: string): boolean;
11
+ /** Resolve one native title exactly, then by case-insensitive substring. */
12
+ export declare function sessionMatches(items: readonly SessionSummary[], value: string, cwd?: string): readonly SessionSummary[];
13
+ export declare function sessionNotFound(value: string): Error;
14
+ /** Format ordinary roots from DSH's native catalog without adding live-process facts. */
15
+ export declare function formatSessionList(items: readonly SessionSummary[], cwd: string | undefined, json: boolean): string;
@@ -0,0 +1,65 @@
1
+ /** Parse only the profile-provided `sessions list` grammar. */
2
+ export function parseSessionListArgs(args) {
3
+ if (args[0] !== 'sessions' || args[1] !== 'list')
4
+ return undefined;
5
+ let all = false;
6
+ let cwd;
7
+ let json = false;
8
+ for (let index = 2; index < args.length; index++) {
9
+ const argument = args[index];
10
+ if (argument === '--all' && !all && cwd === undefined)
11
+ all = true;
12
+ else if (argument === '--json' && !json)
13
+ json = true;
14
+ else if (argument === '--cwd' && cwd === undefined && !all) {
15
+ cwd = args[++index];
16
+ if (cwd === undefined || cwd === '')
17
+ return undefined;
18
+ }
19
+ else
20
+ return undefined;
21
+ }
22
+ return { all, json, ...(cwd === undefined ? {} : { cwd }) };
23
+ }
24
+ export function sessionTitle(item) {
25
+ const title = item.projections?.values.title;
26
+ return typeof title === 'string' && title !== '' ? title : undefined;
27
+ }
28
+ export function isSessionId(value) {
29
+ return /^session-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu.test(value);
30
+ }
31
+ /** Resolve one native title exactly, then by case-insensitive substring. */
32
+ export function sessionMatches(items, value, cwd) {
33
+ const scoped = items.filter(item => item.origin === undefined && (cwd === undefined || item.cwd === cwd));
34
+ const exact = scoped.filter(item => sessionTitle(item) === value);
35
+ return exact.length > 0 ? exact : scoped.filter(item => sessionTitle(item)?.toLocaleLowerCase().includes(value.toLocaleLowerCase()));
36
+ }
37
+ export function sessionNotFound(value) {
38
+ return new Error(`no session named ${JSON.stringify(value)}`);
39
+ }
40
+ function terminalJson(value, space) {
41
+ return JSON.stringify(value, undefined, space).replace(/[\u0080-\u009f]/gu, character => `\\u${(character.codePointAt(0) ?? 0).toString(16).padStart(4, '0')}`);
42
+ }
43
+ function terminalField(value) {
44
+ return terminalJson(value).slice(1, -1);
45
+ }
46
+ /** Format ordinary roots from DSH's native catalog without adding live-process facts. */
47
+ export function formatSessionList(items, cwd, json) {
48
+ const sessions = items
49
+ .filter(item => item.origin === undefined && (cwd === undefined || item.cwd === cwd))
50
+ .map(item => ({
51
+ sessionId: String(item.sessionId),
52
+ title: sessionTitle(item) ?? null,
53
+ ...(item.cwd === undefined ? {} : { cwd: item.cwd }),
54
+ updatedAt: new Date(item.updatedAt).toISOString(),
55
+ }));
56
+ if (json)
57
+ return terminalJson({ version: 1, sessions }, 2);
58
+ const records = sessions.map(item => [
59
+ terminalField(item.sessionId), terminalField(item.title ?? 'Untitled'),
60
+ terminalField(item.cwd ?? 'cwd unavailable'), item.updatedAt,
61
+ ]);
62
+ const headers = ['UUID', 'TITLE', 'CWD', 'UPDATED'];
63
+ const widths = headers.map((header, index) => Math.max(header.length, ...records.map(row => row[index]?.length ?? 0)));
64
+ return [headers, ...records].map(row => row.map((value, index) => index === row.length - 1 ? value : value.padEnd(widths[index] ?? 0)).join(' ')).join('\n');
65
+ }