@compilr-dev/cli 0.6.6 → 0.7.0

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 (31) hide show
  1. package/dist/commands-v2/handlers/core.js +10 -1
  2. package/dist/commands-v2/handlers/index.d.ts +1 -0
  3. package/dist/commands-v2/handlers/index.js +3 -0
  4. package/dist/commands-v2/handlers/settings.js +21 -5
  5. package/dist/commands-v2/handlers/skill.d.ts +10 -0
  6. package/dist/commands-v2/handlers/skill.js +63 -0
  7. package/dist/commands-v2/index.d.ts +1 -1
  8. package/dist/commands-v2/index.js +1 -1
  9. package/dist/commands-v2/registry.d.ts +4 -0
  10. package/dist/commands-v2/registry.js +19 -0
  11. package/dist/compilr-diff-companion.vsix +0 -0
  12. package/dist/index.js +8 -12
  13. package/dist/repl-helpers.d.ts +29 -1
  14. package/dist/repl-helpers.js +77 -7
  15. package/dist/repl-v2.js +29 -3
  16. package/dist/ui/conversation.js +1 -2
  17. package/dist/ui/markdown-renderer.d.ts +43 -0
  18. package/dist/ui/markdown-renderer.js +474 -0
  19. package/dist/ui/overlay/impl/artifact-detail-overlay-v2.js +1 -2
  20. package/dist/ui/overlay/impl/document-detail-overlay-v2.js +1 -2
  21. package/dist/ui/overlay/impl/help-overlay-v2.d.ts +7 -1
  22. package/dist/ui/overlay/impl/help-overlay-v2.js +19 -2
  23. package/dist/ui/overlay/impl/skill-detail-overlay-v2.d.ts +91 -0
  24. package/dist/ui/overlay/impl/skill-detail-overlay-v2.js +863 -0
  25. package/dist/ui/overlay/impl/skill-editor-overlay.d.ts +56 -0
  26. package/dist/ui/overlay/impl/skill-editor-overlay.js +493 -0
  27. package/dist/ui/overlay/impl/skills-overlay-v2.d.ts +83 -0
  28. package/dist/ui/overlay/impl/skills-overlay-v2.js +1095 -0
  29. package/dist/utils/skill-paths.d.ts +21 -0
  30. package/dist/utils/skill-paths.js +44 -0
  31. package/package.json +7 -6
@@ -0,0 +1,863 @@
1
+ /**
2
+ * Skill Detail Overlay V2
3
+ *
4
+ * Fullscreen SKILL.md viewer AND editor with vim-style navigation.
5
+ * Copied from DocumentDetailOverlayV2 — adapted for file-based skills.
6
+ *
7
+ * Modes:
8
+ * - View: rendered markdown, scrollable (j/k, g/G, PgUp/PgDn)
9
+ * - Edit: raw markdown editing with cursor, line numbers, syntax highlighting
10
+ * - Diff: side-by-side diff against upstream SDK version (forked skills only)
11
+ *
12
+ * Features:
13
+ * - Full-screen viewing with markdown rendering
14
+ * - Vim-style navigation
15
+ * - Edit mode with raw markdown editing
16
+ * - Save to disk (Ctrl+S) with validation warnings
17
+ * - Dirty check with save prompt on exit
18
+ * - Diff view for forked skills (D key)
19
+ */
20
+ import { promises as fsAsync } from 'node:fs';
21
+ import chalk from 'chalk';
22
+ import hljs from 'highlight.js';
23
+ import { ttyWrite } from '../../terminal.js';
24
+ // =============================================================================
25
+ // Alternate Screen Buffer Management
26
+ // =============================================================================
27
+ let inAlternateScreen = false;
28
+ function enterAlternateScreen() {
29
+ if (inAlternateScreen)
30
+ return;
31
+ ttyWrite('\x1b[?1049h');
32
+ ttyWrite('\x1b[H');
33
+ inAlternateScreen = true;
34
+ }
35
+ function exitAlternateScreen() {
36
+ if (!inAlternateScreen)
37
+ return;
38
+ ttyWrite('\x1b[?1049l');
39
+ inAlternateScreen = false;
40
+ }
41
+ function setupAlternateScreenCleanup() {
42
+ const cleanup = () => { exitAlternateScreen(); };
43
+ process.on('exit', cleanup);
44
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
45
+ if (process.platform !== 'win32') {
46
+ process.on('SIGTERM', () => { cleanup(); process.exit(143); });
47
+ }
48
+ process.on('uncaughtException', (err) => { cleanup(); console.error('Uncaught exception:', err); process.exit(1); });
49
+ process.on('unhandledRejection', (reason) => { cleanup(); console.error('Unhandled rejection:', reason); process.exit(1); });
50
+ }
51
+ setupAlternateScreenCleanup();
52
+ import { marked } from 'marked';
53
+ import { markedTerminal } from '../../markdown-renderer.js';
54
+ import { BaseOverlayV2 } from '../../base/overlay-base-v2.js';
55
+ import { renderBorder, isCtrlC, isClose, isNavigateUp, isNavigateDown, isUpArrow, isDownArrow, isLeftArrow, isRightArrow, isHome, isEnd, isPageUp, isPageDown, isSpace, isBackspace, isDelete, isEnter, isTab, isEscape, isE, getPrintableChar, } from '../../base/index.js';
56
+ import { getCurrentTheme } from '../../../themes/index.js';
57
+ import * as terminal from '../../terminal.js';
58
+ import { parseSkillMarkdown, diffForkVsUpstream, validateSkillQuality, } from '@compilr-dev/sdk';
59
+ import { highlight as cliHighlight, supportsLanguage } from 'cli-highlight';
60
+ // =============================================================================
61
+ // Syntax Highlighting (from document-detail-overlay-v2.ts)
62
+ // =============================================================================
63
+ const languageAliases = {
64
+ 'sh': 'bash', 'zsh': 'bash', 'fish': 'bash',
65
+ 'console': 'bash', 'terminal': 'bash', 'command': 'bash',
66
+ 'jsx': 'javascript', 'tsx': 'typescript',
67
+ 'htm': 'xml', 'html': 'xml', 'vue': 'xml', 'svelte': 'xml',
68
+ 'text': 'plaintext', 'txt': 'plaintext', 'log': 'plaintext',
69
+ 'output': 'plaintext', 'result': 'plaintext',
70
+ 'jsonc': 'json', 'json5': 'json', 'jsonl': 'json',
71
+ 'conf': 'ini', 'config': 'ini', 'env': 'ini', 'dotenv': 'ini',
72
+ };
73
+ for (const [alias, target] of Object.entries(languageAliases)) {
74
+ try {
75
+ hljs.registerAliases(alias, { languageName: target });
76
+ }
77
+ catch { /* ignore */ }
78
+ }
79
+ // =============================================================================
80
+ // Markdown Rendering
81
+ // =============================================================================
82
+ function getThemedMarkedOptions(termWidth) {
83
+ const theme = getCurrentTheme();
84
+ const primary = chalk.hex(theme.colors.primary);
85
+ const secondary = chalk.hex(theme.colors.secondary || theme.colors.primary);
86
+ const fg = chalk.hex(theme.colors.foreground);
87
+ const muted = chalk.hex(theme.colors.muted);
88
+ const border = chalk.hex(theme.colors.border);
89
+ return {
90
+ reflowText: true,
91
+ width: Math.min(100, termWidth - 4),
92
+ unescape: true,
93
+ tab: 2,
94
+ showSectionPrefix: false,
95
+ heading: primary.bold,
96
+ firstHeading: primary.bold,
97
+ strong: fg.bold,
98
+ em: fg.italic,
99
+ link: secondary,
100
+ href: secondary.underline,
101
+ blockquote: muted.italic,
102
+ del: muted.strikethrough,
103
+ hr: border,
104
+ codespan: primary,
105
+ code: secondary,
106
+ html: muted,
107
+ table: fg,
108
+ listitem: fg,
109
+ paragraph: fg,
110
+ tableOptions: {
111
+ style: { head: ['bold'], border: [] },
112
+ chars: {
113
+ 'top': '─', 'top-mid': '─', 'top-left': '─', 'top-right': '─',
114
+ 'bottom': '─', 'bottom-mid': '─', 'bottom-left': '─', 'bottom-right': '─',
115
+ 'left': ' ', 'left-mid': ' ', 'mid': '─', 'mid-mid': '─',
116
+ 'right': ' ', 'right-mid': ' ', 'middle': ' ',
117
+ },
118
+ },
119
+ };
120
+ }
121
+ function renderMarkdownSync(content, termWidth) {
122
+ const options = getThemedMarkedOptions(termWidth);
123
+ marked.use(markedTerminal(options));
124
+ const rendered = marked.parse(content, { async: false });
125
+ return rendered.split('\n');
126
+ }
127
+ function highlightEditLine(line, codeBlockState, theme) {
128
+ const secondary = chalk.hex(theme.colors.secondary || theme.colors.primary);
129
+ const fenceMatch = line.match(/^(`{3,}|~{3,})(\w*)\s*$/);
130
+ if (fenceMatch) {
131
+ const [, fence, lang] = fenceMatch;
132
+ const fenceChar = fence[0];
133
+ const fenceLength = fence.length;
134
+ if (!codeBlockState.inCodeBlock) {
135
+ return {
136
+ highlighted: secondary(line),
137
+ newState: { inCodeBlock: true, language: lang || 'plaintext', fenceChar, fenceLength },
138
+ };
139
+ }
140
+ else if (fenceChar === codeBlockState.fenceChar && fenceLength >= codeBlockState.fenceLength) {
141
+ return {
142
+ highlighted: secondary(line),
143
+ newState: { inCodeBlock: false, language: '', fenceChar: '', fenceLength: 0 },
144
+ };
145
+ }
146
+ }
147
+ if (codeBlockState.inCodeBlock) {
148
+ return {
149
+ highlighted: highlightCodeLine(line, codeBlockState.language, theme),
150
+ newState: codeBlockState,
151
+ };
152
+ }
153
+ return { highlighted: highlightMarkdownLine(line, theme), newState: codeBlockState };
154
+ }
155
+ function highlightCodeLine(line, language, theme) {
156
+ const langMap = {
157
+ 'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'rb': 'ruby',
158
+ 'sh': 'bash', 'shell': 'bash', 'zsh': 'bash', 'yml': 'yaml', 'md': 'markdown',
159
+ 'jsx': 'javascript', 'tsx': 'typescript',
160
+ };
161
+ const mappedLang = langMap[language.toLowerCase()] || language.toLowerCase();
162
+ if (!supportsLanguage(mappedLang) && mappedLang !== 'plaintext') {
163
+ return chalk.hex(theme.colors.secondary || theme.colors.primary)(line);
164
+ }
165
+ try {
166
+ if (mappedLang === 'plaintext')
167
+ return chalk.hex(theme.colors.secondary || theme.colors.primary)(line);
168
+ return cliHighlight(line, { language: mappedLang, ignoreIllegals: true });
169
+ }
170
+ catch {
171
+ return chalk.hex(theme.colors.secondary || theme.colors.primary)(line);
172
+ }
173
+ }
174
+ function highlightMarkdownLine(line, theme) {
175
+ const primary = chalk.hex(theme.colors.primary);
176
+ const secondary = chalk.hex(theme.colors.secondary || theme.colors.primary);
177
+ const muted = chalk.hex(theme.colors.muted);
178
+ if (!line.trim())
179
+ return line;
180
+ // YAML frontmatter delimiter
181
+ if (line === '---')
182
+ return muted(line);
183
+ // Headers
184
+ const headerMatch = line.match(/^(#{1,6})\s(.*)$/);
185
+ if (headerMatch)
186
+ return primary(headerMatch[1]) + ' ' + primary.bold(headerMatch[2]);
187
+ // Horizontal rule
188
+ if (line.match(/^([-*_])\1{2,}\s*$/))
189
+ return muted(line);
190
+ // Unordered list
191
+ const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/);
192
+ if (ulMatch)
193
+ return ulMatch[1] + secondary(ulMatch[2]) + ' ' + ulMatch[3];
194
+ // Ordered list
195
+ const olMatch = line.match(/^(\s*)(\d+\.)\s+(.*)$/);
196
+ if (olMatch)
197
+ return olMatch[1] + secondary(olMatch[2]) + ' ' + olMatch[3];
198
+ // Blockquote
199
+ const bqMatch = line.match(/^(>\s*)(.*)$/);
200
+ if (bqMatch)
201
+ return muted(bqMatch[1]) + muted.italic(bqMatch[2]);
202
+ // YAML key: value (inside frontmatter)
203
+ const kvMatch = line.match(/^(\s*)([\w-]+)(\s*:\s*)(.*)/);
204
+ if (kvMatch)
205
+ return kvMatch[1] + primary(kvMatch[2]) + muted(kvMatch[3]) + secondary(kvMatch[4]);
206
+ return line;
207
+ }
208
+ // =============================================================================
209
+ // Key helpers
210
+ // =============================================================================
211
+ function isCtrlS(data) {
212
+ return data.length === 1 && data[0] === 0x13;
213
+ }
214
+ function isEditKey(data) {
215
+ return isE(data) || (data.length === 1 && data[0] === 0x69); // 'i'
216
+ }
217
+ const TAB_SIZE = 2;
218
+ // =============================================================================
219
+ // Skill Detail Overlay
220
+ // =============================================================================
221
+ export class SkillDetailOverlayV2 extends BaseOverlayV2 {
222
+ type = 'inline';
223
+ id = 'skill-detail-overlay-v2';
224
+ usesAlternateScreen = true;
225
+ filePath;
226
+ constructor(options) {
227
+ const termWidth = terminal.getTerminalWidth();
228
+ // Render body only (skip frontmatter for view mode)
229
+ const bodyContent = extractBody(options.content);
230
+ const contentLines = renderMarkdownSync(bodyContent, termWidth);
231
+ const rawLines = options.content.split('\n');
232
+ // Validate
233
+ const parsed = parseSkillMarkdown(options.content);
234
+ const warnings = parsed
235
+ ? validateSkillQuality(parsed, options.skillName).map((i) => i.message)
236
+ : ['Invalid frontmatter — required fields: name, description.'];
237
+ // Compute diff if forked
238
+ let diffLines = null;
239
+ if (options.forkedFrom && parsed?.prompt) {
240
+ diffLines = diffForkVsUpstream(parsed.prompt, options.forkedFrom.skill);
241
+ }
242
+ super({
243
+ mode: 'view',
244
+ contentLines,
245
+ scrollOffset: 0,
246
+ rawLines,
247
+ cursorLine: 0,
248
+ cursorColumn: 0,
249
+ editScrollOffset: 0,
250
+ isDirty: false,
251
+ originalContent: options.content,
252
+ showSavePrompt: false,
253
+ saveMessage: null,
254
+ diffLines,
255
+ diffScrollOffset: 0,
256
+ skillName: options.skillName,
257
+ scope: options.scope,
258
+ forkedFrom: options.forkedFrom ?? null,
259
+ boundCommands: options.boundCommands ?? [],
260
+ validationWarnings: warnings,
261
+ });
262
+ this.filePath = options.filePath;
263
+ }
264
+ onMount() { enterAlternateScreen(); }
265
+ onUnmount() { exitAlternateScreen(); }
266
+ // ===========================================================================
267
+ // Render dispatch
268
+ // ===========================================================================
269
+ renderContent(context) {
270
+ if (this.state.mode === 'edit')
271
+ return this.renderEditMode(context);
272
+ if (this.state.mode === 'diff')
273
+ return this.renderDiffMode(context);
274
+ return this.renderViewMode(context);
275
+ }
276
+ // ===========================================================================
277
+ // View Mode
278
+ // ===========================================================================
279
+ renderViewMode(context) {
280
+ const s = context.styles;
281
+ const cols = context.width;
282
+ const rows = context.height;
283
+ const border = renderBorder(cols, s);
284
+ const lines = [];
285
+ // Header
286
+ lines.push(border);
287
+ const scopeLabel = this.state.scope === 'project' ? s.secondary('[project]') : s.muted('[user]');
288
+ let headerExtra = ` ${scopeLabel}`;
289
+ if (this.state.boundCommands.length > 0) {
290
+ headerExtra += ` ${s.muted('→')} ${s.primary(this.state.boundCommands.join(', '))}`;
291
+ }
292
+ if (this.state.forkedFrom) {
293
+ headerExtra += ` ${s.muted(`forked from ${this.state.forkedFrom.skill}`)}`;
294
+ }
295
+ lines.push(` ${s.primaryBold('SKILL')}${s.muted(' │ ')}${chalk.bold(this.state.skillName)}${headerExtra}`);
296
+ lines.push(border);
297
+ // Validation warnings
298
+ if (this.state.validationWarnings.length > 0) {
299
+ lines.push(` ${s.warning(`⚠ ${String(this.state.validationWarnings.length)} warning${this.state.validationWarnings.length > 1 ? 's' : ''}`)}`);
300
+ }
301
+ // Content area
302
+ const headerUsed = lines.length;
303
+ const footerLines = 3;
304
+ const contentHeight = rows - headerUsed - footerLines;
305
+ const totalLines = this.state.contentLines.length;
306
+ const visibleLines = [];
307
+ let physicalLinesUsed = 0;
308
+ for (let i = this.state.scrollOffset; i < totalLines && physicalLinesUsed < contentHeight; i++) {
309
+ const line = this.state.contentLines[i];
310
+ // eslint-disable-next-line no-control-regex
311
+ const visualLen = line.replace(/\x1b\[[0-9;]*m/g, '').length;
312
+ const linePhysicalHeight = Math.max(1, Math.ceil(visualLen / cols));
313
+ if (physicalLinesUsed + linePhysicalHeight <= contentHeight) {
314
+ visibleLines.push(line);
315
+ physicalLinesUsed += linePhysicalHeight;
316
+ }
317
+ else {
318
+ break;
319
+ }
320
+ }
321
+ for (const line of visibleLines)
322
+ lines.push(line);
323
+ for (let i = physicalLinesUsed; i < contentHeight; i++)
324
+ lines.push('');
325
+ // Footer
326
+ const canScrollUp = this.state.scrollOffset > 0;
327
+ const canScrollDown = this.state.scrollOffset + contentHeight < totalLines;
328
+ const arrows = (canScrollUp ? '▲' : ' ') + (canScrollDown ? '▼' : ' ');
329
+ const position = totalLines > 0
330
+ ? `Line ${String(this.state.scrollOffset + 1)}-${String(Math.min(this.state.scrollOffset + contentHeight, totalLines))} of ${String(totalLines)}`
331
+ : '';
332
+ const scrollPercent = totalLines > contentHeight
333
+ ? Math.round(((this.state.scrollOffset + contentHeight) / totalLines) * 100)
334
+ : 100;
335
+ const diffHint = this.state.diffLines ? ' · D Diff' : '';
336
+ lines.push(border);
337
+ lines.push(s.muted(` ${arrows} ${position} (${String(scrollPercent)}%) ↑↓/jk Scroll · e/i Edit${diffHint} · q/Esc Back`));
338
+ lines.push(border);
339
+ return lines;
340
+ }
341
+ // ===========================================================================
342
+ // Edit Mode
343
+ // ===========================================================================
344
+ renderEditMode(context) {
345
+ const s = context.styles;
346
+ const cols = context.width;
347
+ const rows = context.height;
348
+ const border = renderBorder(cols, s);
349
+ const lines = [];
350
+ const editIndicator = this.state.isDirty ? s.warning('[EDIT*]') : s.primary('[EDIT]');
351
+ lines.push(border);
352
+ lines.push(` ${s.primaryBold('SKILL')}${s.muted(' │ ')}${chalk.bold(this.state.skillName)} ${editIndicator}`);
353
+ lines.push(border);
354
+ const headerLines = 3;
355
+ const footerLines = 3;
356
+ const contentHeight = rows - headerLines - footerLines;
357
+ this.ensureCursorVisible(contentHeight);
358
+ const totalLines = this.state.rawLines.length;
359
+ const lineNumWidth = String(totalLines).length + 1;
360
+ const theme = getCurrentTheme();
361
+ // Pre-compute code block state
362
+ let codeBlockState = { inCodeBlock: false, language: '', fenceChar: '', fenceLength: 0 };
363
+ const lineStates = [];
364
+ for (let i = 0; i < totalLines; i++) {
365
+ const line = this.state.rawLines[i];
366
+ const fenceMatch = line.match(/^(`{3,}|~{3,})(\w*)\s*$/);
367
+ if (fenceMatch) {
368
+ const [, fence, lang] = fenceMatch;
369
+ const fenceChar = fence[0];
370
+ const fenceLength = fence.length;
371
+ if (!codeBlockState.inCodeBlock) {
372
+ codeBlockState = { inCodeBlock: true, language: lang || 'plaintext', fenceChar, fenceLength };
373
+ }
374
+ else if (fenceChar === codeBlockState.fenceChar && fenceLength >= codeBlockState.fenceLength) {
375
+ codeBlockState = { inCodeBlock: false, language: '', fenceChar: '', fenceLength: 0 };
376
+ }
377
+ }
378
+ lineStates[i] = { ...codeBlockState };
379
+ }
380
+ for (let i = 0; i < contentHeight; i++) {
381
+ const lineIndex = this.state.editScrollOffset + i;
382
+ if (lineIndex < totalLines) {
383
+ const lineNum = String(lineIndex + 1).padStart(lineNumWidth);
384
+ const lineContent = this.state.rawLines[lineIndex];
385
+ const isCursorLine = lineIndex === this.state.cursorLine;
386
+ if (isCursorLine) {
387
+ const rendered = this.renderLineWithCursor(lineContent, this.state.cursorColumn, cols - lineNumWidth - 2);
388
+ lines.push(s.muted(`${lineNum}│`) + rendered);
389
+ }
390
+ else {
391
+ const maxLen = cols - lineNumWidth - 2;
392
+ const truncated = lineContent.length > maxLen;
393
+ const display = truncated ? lineContent.slice(0, maxLen - 1) : lineContent;
394
+ const prevState = lineIndex > 0 ? lineStates[lineIndex - 1] : { inCodeBlock: false, language: '', fenceChar: '', fenceLength: 0 };
395
+ const { highlighted } = highlightEditLine(display, prevState, theme);
396
+ lines.push(s.muted(`${lineNum}│`) + highlighted + (truncated ? s.muted('…') : ''));
397
+ }
398
+ }
399
+ else {
400
+ const lineNum = ' '.repeat(lineNumWidth);
401
+ lines.push(s.muted(`${lineNum}│`) + s.muted('~'));
402
+ }
403
+ }
404
+ lines.push(border);
405
+ if (this.state.showSavePrompt) {
406
+ lines.push(s.warning(' Unsaved changes. Save? ') + s.primary('(y)') + 'es / ' + s.primary('(n)') + 'o / ' + s.primary('(c)') + 'ancel');
407
+ }
408
+ else if (this.state.saveMessage) {
409
+ lines.push(s.success(` ${this.state.saveMessage}`));
410
+ }
411
+ else {
412
+ const cursorPos = `Ln ${String(this.state.cursorLine + 1)}, Col ${String(this.state.cursorColumn + 1)}`;
413
+ const mod = this.state.isDirty ? s.warning('[modified]') : '';
414
+ lines.push(s.primaryBold(' ── INSERT ── ') + s.muted(cursorPos) + ' ' + mod + s.muted(' Ctrl+S Save · Esc Exit'));
415
+ }
416
+ lines.push(border);
417
+ return lines;
418
+ }
419
+ renderLineWithCursor(line, cursorCol, maxLen) {
420
+ const theme = getCurrentTheme();
421
+ const cursorStyle = chalk.inverse;
422
+ const effectiveCursorCol = Math.min(cursorCol, line.length);
423
+ let viewStart = 0;
424
+ if (effectiveCursorCol >= maxLen - 5)
425
+ viewStart = effectiveCursorCol - maxLen + 10;
426
+ viewStart = Math.max(0, viewStart);
427
+ const viewEnd = viewStart + maxLen;
428
+ const visibleLine = line.slice(viewStart, viewEnd);
429
+ const cursorPosInView = effectiveCursorCol - viewStart;
430
+ let result = '';
431
+ for (let i = 0; i < visibleLine.length; i++) {
432
+ result += i === cursorPosInView ? cursorStyle(visibleLine[i]) : visibleLine[i];
433
+ }
434
+ if (cursorPosInView >= visibleLine.length)
435
+ result += cursorStyle(' ');
436
+ if (viewStart > 0)
437
+ result = chalk.hex(theme.colors.muted)('…') + result.slice(1);
438
+ if (line.length > viewEnd)
439
+ result = result.slice(0, -1) + chalk.hex(theme.colors.muted)('…');
440
+ return result;
441
+ }
442
+ ensureCursorVisible(contentHeight) {
443
+ if (this.state.cursorLine < this.state.editScrollOffset) {
444
+ this.state.editScrollOffset = this.state.cursorLine;
445
+ }
446
+ if (this.state.cursorLine >= this.state.editScrollOffset + contentHeight) {
447
+ this.state.editScrollOffset = this.state.cursorLine - contentHeight + 1;
448
+ }
449
+ }
450
+ // ===========================================================================
451
+ // Diff Mode
452
+ // ===========================================================================
453
+ renderDiffMode(context) {
454
+ const s = context.styles;
455
+ const cols = context.width;
456
+ const rows = context.height;
457
+ const border = renderBorder(cols, s);
458
+ const lines = [];
459
+ const diff = this.state.diffLines ?? [];
460
+ lines.push(border);
461
+ const upstream = this.state.forkedFrom?.skill ?? 'unknown';
462
+ lines.push(` ${s.primaryBold('DIFF')}${s.muted(' │ ')}${chalk.bold(this.state.skillName)} ${s.muted('vs')} ${s.secondary(`sdk:${upstream}`)}`);
463
+ lines.push(border);
464
+ const headerUsed = 3;
465
+ const footerLines = 3;
466
+ const contentHeight = rows - headerUsed - footerLines;
467
+ const totalLines = diff.length;
468
+ for (let i = 0; i < contentHeight; i++) {
469
+ const lineIndex = this.state.diffScrollOffset + i;
470
+ if (lineIndex < totalLines) {
471
+ const d = diff[lineIndex];
472
+ const maxLen = cols - 4;
473
+ const content = d.content.length > maxLen ? d.content.slice(0, maxLen - 1) + '…' : d.content;
474
+ if (d.type === 'added') {
475
+ lines.push(` ${s.success('+')} ${s.success(content)}`);
476
+ }
477
+ else if (d.type === 'removed') {
478
+ lines.push(` ${s.warning('-')} ${s.warning(content)}`);
479
+ }
480
+ else {
481
+ lines.push(` ${s.muted(content)}`);
482
+ }
483
+ }
484
+ else {
485
+ lines.push('');
486
+ }
487
+ }
488
+ const changed = diff.filter((d) => d.type !== 'same').length;
489
+ const canScrollUp = this.state.diffScrollOffset > 0;
490
+ const canScrollDown = this.state.diffScrollOffset + contentHeight < totalLines;
491
+ const arrows = (canScrollUp ? '▲' : ' ') + (canScrollDown ? '▼' : ' ');
492
+ lines.push(border);
493
+ lines.push(s.muted(` ${arrows} ${String(changed)} changed lines · ${String(totalLines)} total · ↑↓/jk Scroll · q/Esc Back`));
494
+ lines.push(border);
495
+ return lines;
496
+ }
497
+ // ===========================================================================
498
+ // Key Handling
499
+ // ===========================================================================
500
+ handleKey(key) {
501
+ const data = key.raw;
502
+ if (this.state.showSavePrompt)
503
+ return this.handleSavePromptKey(data);
504
+ if (this.state.mode === 'edit')
505
+ return this.handleEditModeKey(data);
506
+ if (this.state.mode === 'diff')
507
+ return this.handleDiffModeKey(data);
508
+ return this.handleViewModeKey(data);
509
+ }
510
+ // ===========================================================================
511
+ // View Mode Keys
512
+ // ===========================================================================
513
+ handleViewModeKey(data) {
514
+ const char = getPrintableChar(data);
515
+ const keyStr = data.toString();
516
+ const rows = terminal.getTerminalHeight();
517
+ const contentHeight = rows - 6;
518
+ const totalLines = this.state.contentLines.length;
519
+ const maxScroll = Math.max(0, totalLines - contentHeight);
520
+ if (isCtrlC(data))
521
+ return this.closeWith({ goBack: false, modified: this.state.isDirty });
522
+ if (isClose(data))
523
+ return this.closeWith({ goBack: true, modified: this.state.isDirty });
524
+ // e/i — edit mode
525
+ if (isEditKey(data)) {
526
+ this.state.mode = 'edit';
527
+ this.state.cursorLine = 0;
528
+ this.state.cursorColumn = 0;
529
+ this.state.editScrollOffset = 0;
530
+ this.state.saveMessage = null;
531
+ return this.renderAction();
532
+ }
533
+ // D — diff mode (forked skills only)
534
+ if (char === 'D' && this.state.diffLines) {
535
+ this.state.mode = 'diff';
536
+ this.state.diffScrollOffset = 0;
537
+ return this.renderAction();
538
+ }
539
+ // Scroll
540
+ if (isNavigateUp(data)) {
541
+ if (this.state.scrollOffset > 0)
542
+ this.state.scrollOffset--;
543
+ return this.renderAction();
544
+ }
545
+ if (isNavigateDown(data)) {
546
+ if (this.state.scrollOffset < maxScroll)
547
+ this.state.scrollOffset++;
548
+ return this.renderAction();
549
+ }
550
+ if (keyStr === '\x1b[5~') {
551
+ this.state.scrollOffset = Math.max(0, this.state.scrollOffset - contentHeight);
552
+ return this.renderAction();
553
+ }
554
+ if (keyStr === '\x1b[6~' || isSpace(data)) {
555
+ this.state.scrollOffset = Math.min(maxScroll, this.state.scrollOffset + contentHeight);
556
+ return this.renderAction();
557
+ }
558
+ if (char === 'g') {
559
+ this.state.scrollOffset = 0;
560
+ return this.renderAction();
561
+ }
562
+ if (char === 'G') {
563
+ this.state.scrollOffset = maxScroll;
564
+ return this.renderAction();
565
+ }
566
+ if (isHome(data)) {
567
+ this.state.scrollOffset = 0;
568
+ return this.renderAction();
569
+ }
570
+ if (isEnd(data)) {
571
+ this.state.scrollOffset = maxScroll;
572
+ return this.renderAction();
573
+ }
574
+ return this.noAction();
575
+ }
576
+ // ===========================================================================
577
+ // Diff Mode Keys
578
+ // ===========================================================================
579
+ handleDiffModeKey(data) {
580
+ const char = getPrintableChar(data);
581
+ const rows = terminal.getTerminalHeight();
582
+ const contentHeight = rows - 6;
583
+ const totalLines = this.state.diffLines?.length ?? 0;
584
+ const maxScroll = Math.max(0, totalLines - contentHeight);
585
+ if (isCtrlC(data))
586
+ return this.closeWith({ goBack: false, modified: false });
587
+ if (isClose(data) || isEscape(data)) {
588
+ this.state.mode = 'view';
589
+ return this.renderAction();
590
+ }
591
+ if (isNavigateUp(data)) {
592
+ if (this.state.diffScrollOffset > 0)
593
+ this.state.diffScrollOffset--;
594
+ return this.renderAction();
595
+ }
596
+ if (isNavigateDown(data)) {
597
+ if (this.state.diffScrollOffset < maxScroll)
598
+ this.state.diffScrollOffset++;
599
+ return this.renderAction();
600
+ }
601
+ if (char === 'g') {
602
+ this.state.diffScrollOffset = 0;
603
+ return this.renderAction();
604
+ }
605
+ if (char === 'G') {
606
+ this.state.diffScrollOffset = maxScroll;
607
+ return this.renderAction();
608
+ }
609
+ if (isPageUp(data)) {
610
+ this.state.diffScrollOffset = Math.max(0, this.state.diffScrollOffset - contentHeight);
611
+ return this.renderAction();
612
+ }
613
+ if (isPageDown(data) || isSpace(data)) {
614
+ this.state.diffScrollOffset = Math.min(maxScroll, this.state.diffScrollOffset + contentHeight);
615
+ return this.renderAction();
616
+ }
617
+ return this.noAction();
618
+ }
619
+ // ===========================================================================
620
+ // Edit Mode Keys
621
+ // ===========================================================================
622
+ handleEditModeKey(data) {
623
+ if (this.state.saveMessage)
624
+ this.state.saveMessage = null;
625
+ if (isCtrlC(data))
626
+ return this.closeWith({ goBack: false, modified: this.state.isDirty });
627
+ if (isCtrlS(data)) {
628
+ void this.saveSkill();
629
+ return this.renderAction();
630
+ }
631
+ if (isEscape(data)) {
632
+ if (this.state.isDirty) {
633
+ this.state.showSavePrompt = true;
634
+ return this.renderAction();
635
+ }
636
+ return this.exitEditMode();
637
+ }
638
+ // Navigation
639
+ if (isUpArrow(data))
640
+ return this.moveCursorUp();
641
+ if (isDownArrow(data))
642
+ return this.moveCursorDown();
643
+ if (isLeftArrow(data))
644
+ return this.moveCursorLeft();
645
+ if (isRightArrow(data))
646
+ return this.moveCursorRight();
647
+ if (isHome(data)) {
648
+ this.state.cursorColumn = 0;
649
+ return this.renderAction();
650
+ }
651
+ if (isEnd(data)) {
652
+ this.state.cursorColumn = this.getCurrentLine().length;
653
+ return this.renderAction();
654
+ }
655
+ if (isPageUp(data)) {
656
+ const pageSize = terminal.getTerminalHeight() - 6;
657
+ this.state.cursorLine = Math.max(0, this.state.cursorLine - pageSize);
658
+ this.clampCursorColumn();
659
+ return this.renderAction();
660
+ }
661
+ if (isPageDown(data)) {
662
+ const pageSize = terminal.getTerminalHeight() - 6;
663
+ this.state.cursorLine = Math.min(this.state.rawLines.length - 1, this.state.cursorLine + pageSize);
664
+ this.clampCursorColumn();
665
+ return this.renderAction();
666
+ }
667
+ // Editing
668
+ if (isBackspace(data))
669
+ return this.handleBackspace();
670
+ if (isDelete(data))
671
+ return this.handleDeleteKey();
672
+ if (isEnter(data))
673
+ return this.handleEnter();
674
+ if (isTab(data))
675
+ return this.insertText(' '.repeat(TAB_SIZE));
676
+ const char = getPrintableChar(data);
677
+ if (char)
678
+ return this.insertText(char);
679
+ return this.noAction();
680
+ }
681
+ // ===========================================================================
682
+ // Save Prompt Keys
683
+ // ===========================================================================
684
+ handleSavePromptKey(data) {
685
+ const char = getPrintableChar(data);
686
+ if (char === 'y' || char === 'Y') {
687
+ void this.saveSkill();
688
+ this.state.showSavePrompt = false;
689
+ return this.exitEditMode();
690
+ }
691
+ if (char === 'n' || char === 'N') {
692
+ this.state.rawLines = this.state.originalContent.split('\n');
693
+ this.state.isDirty = false;
694
+ this.state.showSavePrompt = false;
695
+ return this.exitEditMode();
696
+ }
697
+ if (char === 'c' || char === 'C' || isEscape(data)) {
698
+ this.state.showSavePrompt = false;
699
+ return this.renderAction();
700
+ }
701
+ if (isCtrlC(data))
702
+ return this.closeWith({ goBack: false, modified: false });
703
+ return this.noAction();
704
+ }
705
+ // ===========================================================================
706
+ // Cursor Movement
707
+ // ===========================================================================
708
+ moveCursorUp() {
709
+ if (this.state.cursorLine > 0) {
710
+ this.state.cursorLine--;
711
+ this.clampCursorColumn();
712
+ }
713
+ return this.renderAction();
714
+ }
715
+ moveCursorDown() {
716
+ if (this.state.cursorLine < this.state.rawLines.length - 1) {
717
+ this.state.cursorLine++;
718
+ this.clampCursorColumn();
719
+ }
720
+ return this.renderAction();
721
+ }
722
+ moveCursorLeft() {
723
+ if (this.state.cursorColumn > 0) {
724
+ this.state.cursorColumn--;
725
+ }
726
+ else if (this.state.cursorLine > 0) {
727
+ this.state.cursorLine--;
728
+ this.state.cursorColumn = this.getCurrentLine().length;
729
+ }
730
+ return this.renderAction();
731
+ }
732
+ moveCursorRight() {
733
+ const lineLen = this.getCurrentLine().length;
734
+ if (this.state.cursorColumn < lineLen) {
735
+ this.state.cursorColumn++;
736
+ }
737
+ else if (this.state.cursorLine < this.state.rawLines.length - 1) {
738
+ this.state.cursorLine++;
739
+ this.state.cursorColumn = 0;
740
+ }
741
+ return this.renderAction();
742
+ }
743
+ clampCursorColumn() {
744
+ this.state.cursorColumn = Math.min(this.state.cursorColumn, this.getCurrentLine().length);
745
+ }
746
+ getCurrentLine() {
747
+ return this.state.rawLines[this.state.cursorLine] || '';
748
+ }
749
+ // ===========================================================================
750
+ // Text Editing
751
+ // ===========================================================================
752
+ insertText(text) {
753
+ const line = this.getCurrentLine();
754
+ this.state.rawLines[this.state.cursorLine] = line.slice(0, this.state.cursorColumn) + text + line.slice(this.state.cursorColumn);
755
+ this.state.cursorColumn += text.length;
756
+ this.state.isDirty = true;
757
+ return this.renderAction();
758
+ }
759
+ handleBackspace() {
760
+ if (this.state.cursorColumn > 0) {
761
+ const line = this.getCurrentLine();
762
+ this.state.rawLines[this.state.cursorLine] = line.slice(0, this.state.cursorColumn - 1) + line.slice(this.state.cursorColumn);
763
+ this.state.cursorColumn--;
764
+ this.state.isDirty = true;
765
+ }
766
+ else if (this.state.cursorLine > 0) {
767
+ const currentLine = this.getCurrentLine();
768
+ const prevLine = this.state.rawLines[this.state.cursorLine - 1];
769
+ this.state.rawLines[this.state.cursorLine - 1] = prevLine + currentLine;
770
+ this.state.rawLines.splice(this.state.cursorLine, 1);
771
+ this.state.cursorLine--;
772
+ this.state.cursorColumn = prevLine.length;
773
+ this.state.isDirty = true;
774
+ }
775
+ return this.renderAction();
776
+ }
777
+ handleDeleteKey() {
778
+ const line = this.getCurrentLine();
779
+ if (this.state.cursorColumn < line.length) {
780
+ this.state.rawLines[this.state.cursorLine] = line.slice(0, this.state.cursorColumn) + line.slice(this.state.cursorColumn + 1);
781
+ this.state.isDirty = true;
782
+ }
783
+ else if (this.state.cursorLine < this.state.rawLines.length - 1) {
784
+ const nextLine = this.state.rawLines[this.state.cursorLine + 1];
785
+ this.state.rawLines[this.state.cursorLine] = line + nextLine;
786
+ this.state.rawLines.splice(this.state.cursorLine + 1, 1);
787
+ this.state.isDirty = true;
788
+ }
789
+ return this.renderAction();
790
+ }
791
+ handleEnter() {
792
+ const line = this.getCurrentLine();
793
+ this.state.rawLines[this.state.cursorLine] = line.slice(0, this.state.cursorColumn);
794
+ this.state.rawLines.splice(this.state.cursorLine + 1, 0, line.slice(this.state.cursorColumn));
795
+ this.state.cursorLine++;
796
+ this.state.cursorColumn = 0;
797
+ this.state.isDirty = true;
798
+ return this.renderAction();
799
+ }
800
+ // ===========================================================================
801
+ // Save + Validate
802
+ // ===========================================================================
803
+ async saveSkill() {
804
+ const content = this.state.rawLines.join('\n');
805
+ try {
806
+ await fsAsync.writeFile(this.filePath, content);
807
+ this.state.originalContent = content;
808
+ this.state.isDirty = false;
809
+ // Re-validate
810
+ const parsed = parseSkillMarkdown(content);
811
+ const warnings = parsed
812
+ ? validateSkillQuality(parsed, this.state.skillName).map((i) => i.message)
813
+ : ['Invalid frontmatter — required fields: name, description.'];
814
+ this.state.validationWarnings = warnings;
815
+ // Re-compute diff if forked
816
+ if (this.state.forkedFrom && parsed?.prompt) {
817
+ this.state.diffLines = diffForkVsUpstream(parsed.prompt, this.state.forkedFrom.skill);
818
+ }
819
+ if (warnings.length > 0) {
820
+ this.state.saveMessage = `⚠ Saved with ${String(warnings.length)} warning${warnings.length > 1 ? 's' : ''}: ${warnings.join('; ')}`;
821
+ }
822
+ else {
823
+ this.state.saveMessage = '✓ Saved';
824
+ }
825
+ }
826
+ catch (error) {
827
+ this.state.saveMessage = `Error: ${error.message}`;
828
+ }
829
+ this.requestRender();
830
+ }
831
+ exitEditMode() {
832
+ this.state.mode = 'view';
833
+ const content = this.state.rawLines.join('\n');
834
+ const bodyContent = extractBody(content);
835
+ const termWidth = terminal.getTerminalWidth();
836
+ this.state.contentLines = renderMarkdownSync(bodyContent, termWidth);
837
+ this.state.scrollOffset = 0;
838
+ return this.renderAction();
839
+ }
840
+ // ===========================================================================
841
+ // Helpers
842
+ // ===========================================================================
843
+ closeWith(result) {
844
+ return { type: 'close', result };
845
+ }
846
+ renderAction() {
847
+ return { type: 'render' };
848
+ }
849
+ }
850
+ /**
851
+ * Extract the body content from a SKILL.md (skip frontmatter).
852
+ */
853
+ function extractBody(content) {
854
+ if (!content.startsWith('---'))
855
+ return content;
856
+ const lines = content.split('\n');
857
+ for (let i = 1; i < lines.length; i++) {
858
+ if (lines[i] === '---') {
859
+ return lines.slice(i + 1).join('\n').trim();
860
+ }
861
+ }
862
+ return content;
863
+ }