docrev 0.6.13 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tui.js ADDED
@@ -0,0 +1,437 @@
1
+ /**
2
+ * TUI (Text User Interface) components for enhanced visual display
3
+ * Uses box-drawing characters and colors for a richer terminal experience
4
+ */
5
+
6
+ import chalk from 'chalk';
7
+ import * as readline from 'readline';
8
+
9
+ /**
10
+ * Clear the terminal screen
11
+ */
12
+ export function clearScreen() {
13
+ process.stdout.write('\x1B[2J\x1B[H');
14
+ }
15
+
16
+ /**
17
+ * Move cursor to position
18
+ * @param {number} row
19
+ * @param {number} col
20
+ */
21
+ export function moveCursor(row, col) {
22
+ process.stdout.write(`\x1B[${row};${col}H`);
23
+ }
24
+
25
+ /**
26
+ * Get terminal dimensions
27
+ * @returns {{rows: number, cols: number}}
28
+ */
29
+ export function getTerminalSize() {
30
+ return {
31
+ rows: process.stdout.rows || 24,
32
+ cols: process.stdout.columns || 80,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Draw a box with content
38
+ * @param {object} options
39
+ * @param {string} options.title
40
+ * @param {string[]} options.content
41
+ * @param {number} options.width
42
+ * @param {string} options.borderColor
43
+ * @returns {string[]}
44
+ */
45
+ export function drawBox({ title = '', content = [], width = 60, borderColor = 'dim' }) {
46
+ const border = {
47
+ tl: '╭', tr: '╮', bl: '╰', br: '╯',
48
+ h: '─', v: '│',
49
+ };
50
+
51
+ const colorFn = chalk[borderColor] || chalk.dim;
52
+ const lines = [];
53
+
54
+ // Top border with title
55
+ if (title) {
56
+ const titleDisplay = ` ${title} `;
57
+ const remaining = width - titleDisplay.length - 2;
58
+ lines.push(
59
+ colorFn(border.tl + border.h) +
60
+ chalk.bold(titleDisplay) +
61
+ colorFn(border.h.repeat(Math.max(0, remaining)) + border.tr)
62
+ );
63
+ } else {
64
+ lines.push(colorFn(border.tl + border.h.repeat(width - 2) + border.tr));
65
+ }
66
+
67
+ // Content lines
68
+ for (const line of content) {
69
+ const plainLen = stripAnsi(line).length;
70
+ const padding = Math.max(0, width - 4 - plainLen);
71
+ lines.push(
72
+ colorFn(border.v) + ' ' + line + ' '.repeat(padding) + ' ' + colorFn(border.v)
73
+ );
74
+ }
75
+
76
+ // Bottom border
77
+ lines.push(colorFn(border.bl + border.h.repeat(width - 2) + border.br));
78
+
79
+ return lines;
80
+ }
81
+
82
+ /**
83
+ * Draw a status bar at the bottom of the screen
84
+ * @param {string} left - Left-aligned text
85
+ * @param {string} right - Right-aligned text
86
+ * @returns {string}
87
+ */
88
+ export function statusBar(left, right = '') {
89
+ const { cols } = getTerminalSize();
90
+ const leftLen = stripAnsi(left).length;
91
+ const rightLen = stripAnsi(right).length;
92
+ const padding = Math.max(0, cols - leftLen - rightLen);
93
+
94
+ return chalk.inverse(left + ' '.repeat(padding) + right);
95
+ }
96
+
97
+ /**
98
+ * Draw a progress indicator
99
+ * @param {number} current
100
+ * @param {number} total
101
+ * @param {number} width
102
+ * @returns {string}
103
+ */
104
+ export function progressIndicator(current, total, width = 20) {
105
+ const ratio = current / total;
106
+ const filled = Math.round(ratio * width);
107
+ const empty = width - filled;
108
+
109
+ const bar = chalk.cyan('█'.repeat(filled)) + chalk.dim('░'.repeat(empty));
110
+ return `${bar} ${current}/${total}`;
111
+ }
112
+
113
+ /**
114
+ * Format a comment for TUI display
115
+ * @param {object} comment
116
+ * @param {number} index
117
+ * @param {number} total
118
+ * @param {number} width
119
+ * @returns {string[]}
120
+ */
121
+ export function formatCommentCard(comment, index, total, width = 70) {
122
+ const statusIcon = comment.resolved ? chalk.green('✓') : chalk.yellow('○');
123
+ const author = comment.author || 'Anonymous';
124
+
125
+ const content = [];
126
+
127
+ // Author and status line
128
+ content.push(chalk.blue(author) + ' ' + statusIcon);
129
+ content.push('');
130
+
131
+ // Comment text (word-wrap)
132
+ const wrappedText = wordWrap(comment.content, width - 6);
133
+ for (const line of wrappedText) {
134
+ content.push(line);
135
+ }
136
+
137
+ // Context
138
+ if (comment.before) {
139
+ content.push('');
140
+ const context = comment.before.trim().slice(-50);
141
+ content.push(chalk.dim(`Context: "...${context}"`));
142
+ }
143
+
144
+ // Line number
145
+ content.push('');
146
+ content.push(chalk.dim(`Line ${comment.line}`));
147
+
148
+ return drawBox({
149
+ title: `Comment ${index + 1}/${total}`,
150
+ content,
151
+ width,
152
+ borderColor: comment.resolved ? 'green' : 'cyan',
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Draw the action menu
158
+ * @param {string[]} options - Array of [key, description] tuples
159
+ * @returns {string}
160
+ */
161
+ export function actionMenu(options) {
162
+ return options
163
+ .map(([key, desc]) => chalk.bold(`[${key}]`) + chalk.dim(desc))
164
+ .join(' ');
165
+ }
166
+
167
+ /**
168
+ * Word wrap text to fit within width
169
+ * @param {string} text
170
+ * @param {number} width
171
+ * @returns {string[]}
172
+ */
173
+ function wordWrap(text, width) {
174
+ const words = text.split(/\s+/);
175
+ const lines = [];
176
+ let currentLine = '';
177
+
178
+ for (const word of words) {
179
+ if (currentLine.length + word.length + 1 <= width) {
180
+ currentLine += (currentLine ? ' ' : '') + word;
181
+ } else {
182
+ if (currentLine) lines.push(currentLine);
183
+ currentLine = word;
184
+ }
185
+ }
186
+
187
+ if (currentLine) lines.push(currentLine);
188
+ return lines;
189
+ }
190
+
191
+ /**
192
+ * Strip ANSI codes for length calculation
193
+ * @param {string} str
194
+ * @returns {string}
195
+ */
196
+ function stripAnsi(str) {
197
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
198
+ }
199
+
200
+ /**
201
+ * Run TUI comment review session
202
+ * @param {string} text
203
+ * @param {object} options
204
+ * @returns {Promise<{text: string, resolved: number, replied: number, skipped: number}>}
205
+ */
206
+ export async function tuiCommentReview(text, options = {}) {
207
+ const { getComments, setCommentStatus } = await import('./annotations.js');
208
+ const { createDocumentSession } = await import('./undo.js');
209
+ const { author = 'Author', addReply, setStatus } = options;
210
+
211
+ const comments = getComments(text, { pendingOnly: true });
212
+
213
+ if (comments.length === 0) {
214
+ console.log(chalk.green('No pending comments found.'));
215
+ return { text, resolved: 0, replied: 0, skipped: 0 };
216
+ }
217
+
218
+ // Create session with undo support
219
+ const session = createDocumentSession(text);
220
+
221
+ let currentIndex = 0;
222
+ let resolved = 0;
223
+ let replied = 0;
224
+ let skipped = 0;
225
+ let message = ''; // Status message to display
226
+
227
+ // Helper to render current state
228
+ const render = () => {
229
+ clearScreen();
230
+
231
+ const { cols } = getTerminalSize();
232
+ const cardWidth = Math.min(cols - 4, 80);
233
+
234
+ // Header
235
+ console.log(chalk.cyan.bold(` Reviewing ${comments.length} comment(s) as ${author}`));
236
+ const undoInfo = session.info();
237
+ const undoStatus = session.canUndo()
238
+ ? chalk.dim(` | ${undoInfo.undoSteps} undo`)
239
+ : '';
240
+ console.log(chalk.dim(` ${progressIndicator(currentIndex + 1, comments.length)}${undoStatus}`));
241
+ console.log();
242
+
243
+ // Current comment card
244
+ const comment = comments[currentIndex];
245
+ const card = formatCommentCard(comment, currentIndex, comments.length, cardWidth);
246
+ for (const line of card) {
247
+ console.log(' ' + line);
248
+ }
249
+
250
+ console.log();
251
+
252
+ // Status message
253
+ if (message) {
254
+ console.log(' ' + message);
255
+ console.log();
256
+ message = '';
257
+ }
258
+
259
+ // Action menu with undo
260
+ const menuItems = [
261
+ ['r', 'eply'],
262
+ ['m', 'ark resolved'],
263
+ ['s', 'kip'],
264
+ ['n', 'ext'],
265
+ ['p', 'rev'],
266
+ ];
267
+
268
+ if (session.canUndo()) {
269
+ menuItems.push(['u', 'ndo']);
270
+ }
271
+
272
+ menuItems.push(['A', 'll resolve'], ['q', 'uit']);
273
+
274
+ console.log(' ' + actionMenu(menuItems));
275
+
276
+ console.log();
277
+ };
278
+
279
+ // Prompt for keypress
280
+ const promptKey = (validKeys) => {
281
+ return new Promise((resolve) => {
282
+ const rl = readline.createInterface({
283
+ input: process.stdin,
284
+ output: process.stdout,
285
+ });
286
+
287
+ if (process.stdin.isTTY) {
288
+ process.stdin.setRawMode(true);
289
+ }
290
+ process.stdin.resume();
291
+
292
+ process.stdin.once('data', (key) => {
293
+ const char = key.toString();
294
+
295
+ if (process.stdin.isTTY) {
296
+ process.stdin.setRawMode(false);
297
+ }
298
+ rl.close();
299
+
300
+ if (char === '\u0003') {
301
+ // Ctrl+C
302
+ clearScreen();
303
+ process.exit(0);
304
+ }
305
+
306
+ if (validKeys.includes(char.toLowerCase()) || validKeys.includes(char)) {
307
+ resolve(char);
308
+ } else {
309
+ resolve(promptKey(validKeys));
310
+ }
311
+ });
312
+ });
313
+ };
314
+
315
+ // Prompt for text input
316
+ const promptText = (prompt) => {
317
+ return new Promise((resolve) => {
318
+ const rl = readline.createInterface({
319
+ input: process.stdin,
320
+ output: process.stdout,
321
+ });
322
+ rl.question(prompt, (answer) => {
323
+ rl.close();
324
+ resolve(answer);
325
+ });
326
+ });
327
+ };
328
+
329
+ // Main loop
330
+ while (currentIndex < comments.length) {
331
+ render();
332
+
333
+ const validKeys = session.canUndo()
334
+ ? ['r', 'm', 's', 'n', 'p', 'u', 'A', 'q']
335
+ : ['r', 'm', 's', 'n', 'p', 'A', 'q'];
336
+
337
+ const choice = await promptKey(validKeys);
338
+ const comment = comments[currentIndex];
339
+
340
+ switch (choice) {
341
+ case 'q':
342
+ clearScreen();
343
+ console.log(chalk.yellow('Aborted.'));
344
+ return { text: session.getText(), resolved, replied, skipped: comments.length - currentIndex };
345
+
346
+ case 'u':
347
+ // Undo last change
348
+ if (session.canUndo()) {
349
+ const undone = session.undo();
350
+ if (undone) {
351
+ message = chalk.yellow(`Undone: ${undone.description}`);
352
+ // Adjust counters (approximate)
353
+ if (undone.description.includes('Resolved')) resolved = Math.max(0, resolved - 1);
354
+ if (undone.description.includes('Reply')) replied = Math.max(0, replied - 1);
355
+ if (currentIndex > 0) currentIndex--;
356
+ }
357
+ }
358
+ break;
359
+
360
+ case 'A':
361
+ // Resolve all remaining
362
+ let newText = session.getText();
363
+ for (let j = currentIndex; j < comments.length; j++) {
364
+ if (setStatus) {
365
+ newText = setStatus(newText, comments[j], true);
366
+ }
367
+ }
368
+ session.applyChange(newText, `Resolved ${comments.length - currentIndex} comments`);
369
+ resolved += comments.length - currentIndex;
370
+ currentIndex = comments.length;
371
+ break;
372
+
373
+ case 'm':
374
+ if (setStatus) {
375
+ const newText = setStatus(session.getText(), comment, true);
376
+ session.applyChange(newText, `Resolved comment #${currentIndex + 1}`);
377
+ }
378
+ resolved++;
379
+ currentIndex++;
380
+ break;
381
+
382
+ case 'r':
383
+ console.log();
384
+ const replyText = await promptText(chalk.cyan(' Reply: '));
385
+ if (replyText.trim() && addReply) {
386
+ const newText = addReply(session.getText(), comment, author, replyText.trim());
387
+ session.applyChange(newText, `Reply to comment #${currentIndex + 1}`);
388
+ replied++;
389
+ }
390
+ currentIndex++;
391
+ break;
392
+
393
+ case 's':
394
+ skipped++;
395
+ currentIndex++;
396
+ break;
397
+
398
+ case 'n':
399
+ if (currentIndex < comments.length - 1) {
400
+ currentIndex++;
401
+ }
402
+ break;
403
+
404
+ case 'p':
405
+ if (currentIndex > 0) {
406
+ currentIndex--;
407
+ }
408
+ break;
409
+ }
410
+ }
411
+
412
+ // Summary
413
+ clearScreen();
414
+ console.log(chalk.cyan.bold(' Review Complete'));
415
+ console.log();
416
+
417
+ const undoInfo = session.info();
418
+ const summaryBox = drawBox({
419
+ title: 'Summary',
420
+ content: [
421
+ chalk.green(`Resolved: ${resolved}`),
422
+ chalk.blue(`Replied: ${replied}`),
423
+ chalk.yellow(`Skipped: ${skipped}`),
424
+ undoInfo.undoSteps > 1 ? chalk.dim(`Changes: ${undoInfo.undoSteps}`) : '',
425
+ ].filter(Boolean),
426
+ width: 30,
427
+ borderColor: 'cyan',
428
+ });
429
+
430
+ for (const line of summaryBox) {
431
+ console.log(' ' + line);
432
+ }
433
+
434
+ console.log();
435
+
436
+ return { text: session.getText(), resolved, replied, skipped };
437
+ }
package/lib/undo.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * In-session undo stack for comment/annotation operations
3
+ *
4
+ * Provides undo/redo functionality during interactive sessions
5
+ */
6
+
7
+ /**
8
+ * Create an undo stack
9
+ * @param {number} maxSize - Maximum number of states to store
10
+ * @returns {object} Undo stack controller
11
+ */
12
+ export function createUndoStack(maxSize = 50) {
13
+ const stack = [];
14
+ let position = -1;
15
+
16
+ return {
17
+ /**
18
+ * Push a new state onto the stack
19
+ * @param {any} state - State to save
20
+ * @param {string} description - Description of the change
21
+ */
22
+ push(state, description = '') {
23
+ // Remove any states after current position (for redo)
24
+ if (position < stack.length - 1) {
25
+ stack.splice(position + 1);
26
+ }
27
+
28
+ // Add new state
29
+ stack.push({
30
+ state: typeof state === 'string' ? state : JSON.parse(JSON.stringify(state)),
31
+ description,
32
+ timestamp: Date.now(),
33
+ });
34
+
35
+ // Enforce max size
36
+ while (stack.length > maxSize) {
37
+ stack.shift();
38
+ }
39
+
40
+ position = stack.length - 1;
41
+ },
42
+
43
+ /**
44
+ * Undo to previous state
45
+ * @returns {{state: any, description: string}|null}
46
+ */
47
+ undo() {
48
+ if (position <= 0) {
49
+ return null;
50
+ }
51
+
52
+ position--;
53
+ return stack[position];
54
+ },
55
+
56
+ /**
57
+ * Redo to next state
58
+ * @returns {{state: any, description: string}|null}
59
+ */
60
+ redo() {
61
+ if (position >= stack.length - 1) {
62
+ return null;
63
+ }
64
+
65
+ position++;
66
+ return stack[position];
67
+ },
68
+
69
+ /**
70
+ * Get current state
71
+ * @returns {{state: any, description: string}|null}
72
+ */
73
+ current() {
74
+ if (position < 0 || position >= stack.length) {
75
+ return null;
76
+ }
77
+ return stack[position];
78
+ },
79
+
80
+ /**
81
+ * Check if undo is available
82
+ * @returns {boolean}
83
+ */
84
+ canUndo() {
85
+ return position > 0;
86
+ },
87
+
88
+ /**
89
+ * Check if redo is available
90
+ * @returns {boolean}
91
+ */
92
+ canRedo() {
93
+ return position < stack.length - 1;
94
+ },
95
+
96
+ /**
97
+ * Get stack info
98
+ * @returns {{position: number, size: number, undoSteps: number, redoSteps: number}}
99
+ */
100
+ info() {
101
+ return {
102
+ position,
103
+ size: stack.length,
104
+ undoSteps: position,
105
+ redoSteps: stack.length - position - 1,
106
+ };
107
+ },
108
+
109
+ /**
110
+ * Get history of changes
111
+ * @param {number} limit - Max items to return
112
+ * @returns {Array<{description: string, current: boolean, index: number}>}
113
+ */
114
+ history(limit = 10) {
115
+ const start = Math.max(0, position - Math.floor(limit / 2));
116
+ const end = Math.min(stack.length, start + limit);
117
+
118
+ return stack.slice(start, end).map((item, i) => ({
119
+ description: item.description,
120
+ current: start + i === position,
121
+ index: start + i,
122
+ }));
123
+ },
124
+
125
+ /**
126
+ * Clear the stack
127
+ */
128
+ clear() {
129
+ stack.length = 0;
130
+ position = -1;
131
+ },
132
+
133
+ /**
134
+ * Get the full stack (for debugging)
135
+ * @returns {Array}
136
+ */
137
+ getStack() {
138
+ return [...stack];
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Create a document session with undo support
145
+ * @param {string} initialText - Initial document text
146
+ * @returns {object} Session controller
147
+ */
148
+ export function createDocumentSession(initialText) {
149
+ const undoStack = createUndoStack();
150
+
151
+ // Save initial state
152
+ undoStack.push(initialText, 'Initial state');
153
+
154
+ return {
155
+ /**
156
+ * Get current text
157
+ * @returns {string}
158
+ */
159
+ getText() {
160
+ const current = undoStack.current();
161
+ return current ? current.state : initialText;
162
+ },
163
+
164
+ /**
165
+ * Apply a change
166
+ * @param {string} newText - New document text
167
+ * @param {string} description - What changed
168
+ */
169
+ applyChange(newText, description) {
170
+ undoStack.push(newText, description);
171
+ },
172
+
173
+ /**
174
+ * Undo last change
175
+ * @returns {{text: string, description: string}|null}
176
+ */
177
+ undo() {
178
+ const result = undoStack.undo();
179
+ if (result) {
180
+ return {
181
+ text: result.state,
182
+ description: result.description,
183
+ };
184
+ }
185
+ return null;
186
+ },
187
+
188
+ /**
189
+ * Redo last undone change
190
+ * @returns {{text: string, description: string}|null}
191
+ */
192
+ redo() {
193
+ const result = undoStack.redo();
194
+ if (result) {
195
+ return {
196
+ text: result.state,
197
+ description: result.description,
198
+ };
199
+ }
200
+ return null;
201
+ },
202
+
203
+ /**
204
+ * Check if undo is available
205
+ * @returns {boolean}
206
+ */
207
+ canUndo() {
208
+ return undoStack.canUndo();
209
+ },
210
+
211
+ /**
212
+ * Check if redo is available
213
+ * @returns {boolean}
214
+ */
215
+ canRedo() {
216
+ return undoStack.canRedo();
217
+ },
218
+
219
+ /**
220
+ * Get stack info
221
+ * @returns {object}
222
+ */
223
+ info() {
224
+ return undoStack.info();
225
+ },
226
+
227
+ /**
228
+ * Get change history
229
+ * @param {number} limit
230
+ * @returns {Array}
231
+ */
232
+ history(limit = 10) {
233
+ return undoStack.history(limit);
234
+ },
235
+ };
236
+ }