@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.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/lib/application.d.ts +43 -0
- package/lib/application.js +260 -0
- package/lib/at-trigger.d.ts +8 -0
- package/lib/at-trigger.js +13 -0
- package/lib/catalogs.d.ts +23 -0
- package/lib/catalogs.js +218 -0
- package/lib/clipboard-image.d.ts +3 -0
- package/lib/clipboard-image.js +74 -0
- package/lib/completion-trigger.d.ts +2 -0
- package/lib/completion-trigger.js +7 -0
- package/lib/external-editor.d.ts +2 -0
- package/lib/external-editor.js +39 -0
- package/lib/history-records.d.ts +4 -0
- package/lib/history-records.js +14 -0
- package/lib/image-input.d.ts +8 -0
- package/lib/image-input.js +37 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +250 -0
- package/lib/input.d.ts +10 -0
- package/lib/input.js +220 -0
- package/lib/mouse-input.d.ts +1 -0
- package/lib/mouse-input.js +6 -0
- package/lib/presentation.d.ts +9 -0
- package/lib/presentation.js +54 -0
- package/lib/renderer.d.ts +19 -0
- package/lib/renderer.js +414 -0
- package/lib/rewind.d.ts +18 -0
- package/lib/rewind.js +84 -0
- package/lib/session-export.d.ts +3 -0
- package/lib/session-export.js +27 -0
- package/lib/session-list.d.ts +15 -0
- package/lib/session-list.js +65 -0
- package/lib/session-runtime.d.ts +35 -0
- package/lib/session-runtime.js +1027 -0
- package/lib/shell-command.d.ts +9 -0
- package/lib/shell-command.js +62 -0
- package/lib/state.d.ts +493 -0
- package/lib/state.js +641 -0
- package/lib/terminal-guard.d.ts +14 -0
- package/lib/terminal-guard.js +76 -0
- package/lib/tool-presentation.d.ts +14 -0
- package/lib/tool-presentation.js +156 -0
- package/lib/transcript.d.ts +12 -0
- package/lib/transcript.js +304 -0
- package/lib/tui-root.d.ts +20 -0
- package/lib/tui-root.js +25 -0
- package/package.json +65 -0
- package/validated-dsh-versions.json +3 -0
package/lib/renderer.js
ADDED
|
@@ -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
|
+
}
|
package/lib/rewind.d.ts
ADDED
|
@@ -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
|
+
}
|