specsmd 0.0.0-dev.86 → 0.0.0-dev.87

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 (42) hide show
  1. package/README.md +15 -0
  2. package/bin/cli.js +15 -1
  3. package/flows/fire/agents/builder/agent.md +2 -2
  4. package/flows/fire/agents/builder/skills/code-review/SKILL.md +1 -1
  5. package/flows/fire/agents/builder/skills/run-execute/SKILL.md +16 -7
  6. package/flows/fire/agents/builder/skills/run-execute/scripts/complete-run.cjs +22 -3
  7. package/flows/fire/agents/builder/skills/run-execute/scripts/init-run.cjs +63 -20
  8. package/flows/fire/agents/builder/skills/run-execute/scripts/update-checkpoint.cjs +254 -0
  9. package/flows/fire/agents/builder/skills/run-execute/scripts/update-phase.cjs +17 -6
  10. package/flows/fire/agents/builder/skills/run-status/SKILL.md +1 -1
  11. package/flows/fire/agents/orchestrator/agent.md +1 -1
  12. package/flows/fire/agents/orchestrator/skills/status/SKILL.md +2 -2
  13. package/flows/fire/memory-bank.yaml +4 -4
  14. package/lib/dashboard/aidlc/parser.js +581 -0
  15. package/lib/dashboard/fire/model.js +382 -0
  16. package/lib/dashboard/fire/parser.js +470 -0
  17. package/lib/dashboard/flow-detect.js +86 -0
  18. package/lib/dashboard/git/changes.js +362 -0
  19. package/lib/dashboard/git/worktrees.js +248 -0
  20. package/lib/dashboard/index.js +709 -0
  21. package/lib/dashboard/runtime/watch-runtime.js +122 -0
  22. package/lib/dashboard/simple/parser.js +293 -0
  23. package/lib/dashboard/tui/app.js +1675 -0
  24. package/lib/dashboard/tui/components/error-banner.js +35 -0
  25. package/lib/dashboard/tui/components/header.js +60 -0
  26. package/lib/dashboard/tui/components/help-footer.js +15 -0
  27. package/lib/dashboard/tui/components/stats-strip.js +35 -0
  28. package/lib/dashboard/tui/file-entries.js +383 -0
  29. package/lib/dashboard/tui/flow-builders.js +991 -0
  30. package/lib/dashboard/tui/git-builders.js +218 -0
  31. package/lib/dashboard/tui/helpers.js +236 -0
  32. package/lib/dashboard/tui/overlays.js +242 -0
  33. package/lib/dashboard/tui/preview.js +220 -0
  34. package/lib/dashboard/tui/renderer.js +76 -0
  35. package/lib/dashboard/tui/row-builders.js +797 -0
  36. package/lib/dashboard/tui/sections.js +45 -0
  37. package/lib/dashboard/tui/store.js +44 -0
  38. package/lib/dashboard/tui/views/overview-view.js +61 -0
  39. package/lib/dashboard/tui/views/runs-view.js +93 -0
  40. package/lib/dashboard/tui/worktree-builders.js +229 -0
  41. package/lib/installers/CodexInstaller.js +72 -1
  42. package/package.json +7 -3
@@ -0,0 +1,218 @@
1
+ const path = require('path');
2
+ const { spawnSync } = require('child_process');
3
+ const { fileExists } = require('./helpers');
4
+
5
+ function getGitChangesSnapshot(snapshot) {
6
+ const gitChanges = snapshot?.gitChanges;
7
+ if (!gitChanges || typeof gitChanges !== 'object') {
8
+ return {
9
+ available: false,
10
+ branch: '(unavailable)',
11
+ upstream: null,
12
+ ahead: 0,
13
+ behind: 0,
14
+ counts: {
15
+ total: 0,
16
+ staged: 0,
17
+ unstaged: 0,
18
+ untracked: 0,
19
+ conflicted: 0
20
+ },
21
+ staged: [],
22
+ unstaged: [],
23
+ untracked: [],
24
+ conflicted: [],
25
+ clean: true
26
+ };
27
+ }
28
+ return {
29
+ ...gitChanges,
30
+ counts: gitChanges.counts || {
31
+ total: 0,
32
+ staged: 0,
33
+ unstaged: 0,
34
+ untracked: 0,
35
+ conflicted: 0
36
+ },
37
+ staged: Array.isArray(gitChanges.staged) ? gitChanges.staged : [],
38
+ unstaged: Array.isArray(gitChanges.unstaged) ? gitChanges.unstaged : [],
39
+ untracked: Array.isArray(gitChanges.untracked) ? gitChanges.untracked : [],
40
+ conflicted: Array.isArray(gitChanges.conflicted) ? gitChanges.conflicted : []
41
+ };
42
+ }
43
+
44
+ function readGitCommandLines(repoRoot, args, options = {}) {
45
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '' || !Array.isArray(args) || args.length === 0) {
46
+ return [];
47
+ }
48
+
49
+ const acceptedStatuses = Array.isArray(options.acceptedStatuses) && options.acceptedStatuses.length > 0
50
+ ? options.acceptedStatuses
51
+ : [0];
52
+
53
+ const result = spawnSync('git', args, {
54
+ cwd: repoRoot,
55
+ encoding: 'utf8',
56
+ maxBuffer: 8 * 1024 * 1024
57
+ });
58
+
59
+ if (result.error) {
60
+ return [];
61
+ }
62
+
63
+ if (typeof result.status === 'number' && !acceptedStatuses.includes(result.status)) {
64
+ return [];
65
+ }
66
+
67
+ const lines = String(result.stdout || '')
68
+ .split(/\r?\n/)
69
+ .map((line) => line.trim())
70
+ .filter(Boolean);
71
+
72
+ const limit = Number.isFinite(options.limit) ? Math.max(1, Math.floor(options.limit)) : null;
73
+ if (limit == null || lines.length <= limit) {
74
+ return lines;
75
+ }
76
+ return lines.slice(0, limit);
77
+ }
78
+
79
+ function buildGitStatusPanelLines(snapshot) {
80
+ const git = getGitChangesSnapshot(snapshot);
81
+ if (!git.available) {
82
+ return [{
83
+ text: 'Repository unavailable in selected worktree',
84
+ color: 'red',
85
+ bold: true
86
+ }];
87
+ }
88
+
89
+ const tracking = git.upstream
90
+ ? `${git.upstream} (${git.ahead > 0 ? `ahead ${git.ahead}` : 'ahead 0'}, ${git.behind > 0 ? `behind ${git.behind}` : 'behind 0'})`
91
+ : 'no upstream';
92
+
93
+ return [
94
+ {
95
+ text: `branch: ${git.branch}${git.detached ? ' [detached]' : ''}`,
96
+ color: 'green',
97
+ bold: true
98
+ },
99
+ {
100
+ text: `tracking: ${tracking}`,
101
+ color: 'gray',
102
+ bold: false
103
+ },
104
+ {
105
+ text: `changes: ${git.counts.total || 0} total`,
106
+ color: 'gray',
107
+ bold: false
108
+ },
109
+ {
110
+ text: `staged ${git.counts.staged || 0} | unstaged ${git.counts.unstaged || 0}`,
111
+ color: 'yellow',
112
+ bold: false
113
+ },
114
+ {
115
+ text: `untracked ${git.counts.untracked || 0} | conflicts ${git.counts.conflicted || 0}`,
116
+ color: 'yellow',
117
+ bold: false
118
+ }
119
+ ];
120
+ }
121
+
122
+ function buildGitCommitRows(snapshot) {
123
+ const git = getGitChangesSnapshot(snapshot);
124
+ if (!git.available) {
125
+ return [{
126
+ kind: 'info',
127
+ key: 'git:commits:unavailable',
128
+ label: 'No commit history (git unavailable)',
129
+ selectable: false
130
+ }];
131
+ }
132
+
133
+ const commitLines = readGitCommandLines(git.rootPath, [
134
+ '-c',
135
+ 'color.ui=false',
136
+ 'log',
137
+ '--date=relative',
138
+ '--pretty=format:%h %s',
139
+ '--max-count=30'
140
+ ], { limit: 30 });
141
+
142
+ if (commitLines.length === 0) {
143
+ return [{
144
+ kind: 'info',
145
+ key: 'git:commits:empty',
146
+ label: 'No commits found',
147
+ selectable: false
148
+ }];
149
+ }
150
+
151
+ return commitLines.map((line, index) => {
152
+ const firstSpace = line.indexOf(' ');
153
+ const commitHash = firstSpace > 0 ? line.slice(0, firstSpace) : '';
154
+ const message = firstSpace > 0 ? line.slice(firstSpace + 1) : line;
155
+ const label = commitHash ? `${commitHash} ${message}` : message;
156
+
157
+ return {
158
+ kind: 'git-commit',
159
+ key: `git:commit:${commitHash || index}:${index}`,
160
+ label,
161
+ commitHash,
162
+ repoRoot: git.rootPath,
163
+ previewType: 'git-commit-diff',
164
+ selectable: true
165
+ };
166
+ });
167
+ }
168
+
169
+ function buildGitChangeGroups(snapshot) {
170
+ const git = getGitChangesSnapshot(snapshot);
171
+
172
+ if (!git.available) {
173
+ return [];
174
+ }
175
+
176
+ const makeFiles = (items, scope) => items.map((item) => ({
177
+ label: item.relativePath,
178
+ path: item.path || path.join(git.rootPath || snapshot?.workspacePath || '', item.relativePath || ''),
179
+ scope,
180
+ allowMissing: true,
181
+ previewType: 'git-diff',
182
+ repoRoot: git.rootPath || snapshot?.workspacePath || '',
183
+ relativePath: item.relativePath || '',
184
+ bucket: item.bucket || scope
185
+ }));
186
+
187
+ const groups = [];
188
+ groups.push({
189
+ key: 'git:staged',
190
+ label: `staged (${git.counts.staged || 0})`,
191
+ files: makeFiles(git.staged, 'staged')
192
+ });
193
+ groups.push({
194
+ key: 'git:unstaged',
195
+ label: `unstaged (${git.counts.unstaged || 0})`,
196
+ files: makeFiles(git.unstaged, 'unstaged')
197
+ });
198
+ groups.push({
199
+ key: 'git:untracked',
200
+ label: `untracked (${git.counts.untracked || 0})`,
201
+ files: makeFiles(git.untracked, 'untracked')
202
+ });
203
+ groups.push({
204
+ key: 'git:conflicted',
205
+ label: `conflicts (${git.counts.conflicted || 0})`,
206
+ files: makeFiles(git.conflicted, 'conflicted')
207
+ });
208
+
209
+ return groups;
210
+ }
211
+
212
+ module.exports = {
213
+ getGitChangesSnapshot,
214
+ readGitCommandLines,
215
+ buildGitStatusPanelLines,
216
+ buildGitCommitRows,
217
+ buildGitChangeGroups
218
+ };
@@ -0,0 +1,236 @@
1
+ const fs = require('fs');
2
+ const stringWidthModule = require('string-width');
3
+ const sliceAnsiModule = require('slice-ansi');
4
+
5
+ const stringWidth = typeof stringWidthModule === 'function'
6
+ ? stringWidthModule
7
+ : stringWidthModule.default;
8
+ const sliceAnsi = typeof sliceAnsiModule === 'function'
9
+ ? sliceAnsiModule
10
+ : sliceAnsiModule.default;
11
+
12
+ function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
13
+ if (!error) {
14
+ return {
15
+ code: defaultCode,
16
+ message: 'Unknown dashboard error.'
17
+ };
18
+ }
19
+
20
+ if (typeof error === 'string') {
21
+ return {
22
+ code: defaultCode,
23
+ message: error
24
+ };
25
+ }
26
+
27
+ if (typeof error === 'object') {
28
+ return {
29
+ code: error.code || defaultCode,
30
+ message: error.message || 'Unknown dashboard error.',
31
+ details: error.details,
32
+ path: error.path,
33
+ hint: error.hint
34
+ };
35
+ }
36
+
37
+ return {
38
+ code: defaultCode,
39
+ message: String(error)
40
+ };
41
+ }
42
+
43
+ function safeJsonHash(value) {
44
+ try {
45
+ return JSON.stringify(value, (key, nestedValue) => {
46
+ if (key === 'generatedAt') {
47
+ return undefined;
48
+ }
49
+ return nestedValue;
50
+ });
51
+ } catch {
52
+ return String(value);
53
+ }
54
+ }
55
+
56
+ function resolveIconSet() {
57
+ const mode = (process.env.SPECSMD_ICON_SET || 'auto').toLowerCase();
58
+
59
+ const ascii = {
60
+ runs: '[R]',
61
+ overview: '[O]',
62
+ health: '[H]',
63
+ git: '[G]',
64
+ runFile: '*',
65
+ activeFile: '>',
66
+ groupCollapsed: '>',
67
+ groupExpanded: 'v'
68
+ };
69
+
70
+ const nerd = {
71
+ runs: '󰑮',
72
+ overview: '󰍉',
73
+ health: '󰓦',
74
+ git: '󰊢',
75
+ runFile: '󰈔',
76
+ activeFile: '󰜴',
77
+ groupCollapsed: '󰐕',
78
+ groupExpanded: '󰐗'
79
+ };
80
+
81
+ if (mode === 'ascii') {
82
+ return ascii;
83
+ }
84
+ if (mode === 'nerd') {
85
+ return nerd;
86
+ }
87
+
88
+ const locale = `${process.env.LC_ALL || ''}${process.env.LC_CTYPE || ''}${process.env.LANG || ''}`;
89
+ const isUtf8 = /utf-?8/i.test(locale);
90
+ const looksLikeVsCodeTerminal = (process.env.TERM_PROGRAM || '').toLowerCase().includes('vscode');
91
+
92
+ return isUtf8 && looksLikeVsCodeTerminal ? nerd : ascii;
93
+ }
94
+
95
+ function truncate(value, width) {
96
+ const text = String(value ?? '');
97
+ if (!Number.isFinite(width)) {
98
+ return text;
99
+ }
100
+
101
+ const safeWidth = Math.max(0, Math.floor(width));
102
+ if (safeWidth === 0) {
103
+ return '';
104
+ }
105
+
106
+ if (stringWidth(text) <= safeWidth) {
107
+ return text;
108
+ }
109
+
110
+ if (safeWidth <= 3) {
111
+ return sliceAnsi(text, 0, safeWidth);
112
+ }
113
+
114
+ const ellipsis = '...';
115
+ const bodyWidth = Math.max(0, safeWidth - stringWidth(ellipsis));
116
+ return `${sliceAnsi(text, 0, bodyWidth)}${ellipsis}`;
117
+ }
118
+
119
+ function resolveFrameWidth(columns) {
120
+ const safeColumns = Number.isFinite(columns) ? Math.max(1, Math.floor(columns)) : 120;
121
+ return safeColumns > 24 ? safeColumns - 1 : safeColumns;
122
+ }
123
+
124
+ function normalizePanelLine(line) {
125
+ if (line && typeof line === 'object' && !Array.isArray(line)) {
126
+ return {
127
+ text: typeof line.text === 'string' ? line.text : String(line.text ?? ''),
128
+ color: line.color,
129
+ bold: Boolean(line.bold),
130
+ selected: Boolean(line.selected),
131
+ loading: Boolean(line.loading)
132
+ };
133
+ }
134
+
135
+ return {
136
+ text: String(line ?? ''),
137
+ color: undefined,
138
+ bold: false,
139
+ selected: false,
140
+ loading: false
141
+ };
142
+ }
143
+
144
+ function fitLines(lines, maxLines, width) {
145
+ const safeLines = (Array.isArray(lines) ? lines : []).map((line) => {
146
+ const normalized = normalizePanelLine(line);
147
+ return {
148
+ ...normalized,
149
+ text: truncate(normalized.text, width)
150
+ };
151
+ });
152
+
153
+ if (safeLines.length <= maxLines) {
154
+ return safeLines;
155
+ }
156
+
157
+ const selectedIndex = safeLines.findIndex((line) => line.selected);
158
+ if (selectedIndex >= 0) {
159
+ const windowSize = Math.max(1, maxLines);
160
+ let start = selectedIndex - Math.floor(windowSize / 2);
161
+ start = Math.max(0, start);
162
+ start = Math.min(start, Math.max(0, safeLines.length - windowSize));
163
+ return safeLines.slice(start, start + windowSize);
164
+ }
165
+
166
+ const visible = safeLines.slice(0, Math.max(1, maxLines - 1));
167
+ visible.push({
168
+ text: truncate(`... +${safeLines.length - visible.length} more`, width),
169
+ color: 'gray',
170
+ bold: false
171
+ });
172
+ return visible;
173
+ }
174
+
175
+ function formatTime(value) {
176
+ if (!value) {
177
+ return 'n/a';
178
+ }
179
+
180
+ const date = new Date(value);
181
+ if (Number.isNaN(date.getTime())) {
182
+ return value;
183
+ }
184
+
185
+ return date.toLocaleTimeString();
186
+ }
187
+
188
+ function fileExists(filePath) {
189
+ try {
190
+ return fs.statSync(filePath).isFile();
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ function readFileTextSafe(filePath) {
197
+ try {
198
+ return fs.readFileSync(filePath, 'utf8');
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ function normalizeToken(value) {
205
+ if (typeof value !== 'string') {
206
+ return '';
207
+ }
208
+ return value.toLowerCase().trim().replace(/[\s-]+/g, '_');
209
+ }
210
+
211
+ function clampIndex(value, length) {
212
+ if (!Number.isFinite(value)) {
213
+ return 0;
214
+ }
215
+ if (!Number.isFinite(length) || length <= 0) {
216
+ return 0;
217
+ }
218
+ return Math.max(0, Math.min(length - 1, Math.floor(value)));
219
+ }
220
+
221
+ module.exports = {
222
+ stringWidth,
223
+ sliceAnsi,
224
+ toDashboardError,
225
+ safeJsonHash,
226
+ resolveIconSet,
227
+ truncate,
228
+ resolveFrameWidth,
229
+ normalizePanelLine,
230
+ fitLines,
231
+ formatTime,
232
+ fileExists,
233
+ readFileTextSafe,
234
+ normalizeToken,
235
+ clampIndex
236
+ };
@@ -0,0 +1,242 @@
1
+ const { truncate } = require('./helpers');
2
+
3
+ function buildQuickHelpText(view, options = {}) {
4
+ const {
5
+ flow = 'fire',
6
+ previewOpen = false,
7
+ availableFlowCount = 1,
8
+ hasWorktrees = false
9
+ } = options;
10
+ const isAidlc = String(flow || '').toLowerCase() === 'aidlc';
11
+ const isSimple = String(flow || '').toLowerCase() === 'simple';
12
+ const activeLabel = isAidlc ? 'active bolt' : (isSimple ? 'active spec' : 'active run');
13
+
14
+ const parts = ['1/2/3/4/5 tabs', 'g/G sections'];
15
+
16
+ if (view === 'runs' || view === 'intents' || view === 'completed' || view === 'health' || view === 'git') {
17
+ if (previewOpen) {
18
+ parts.push('tab pane', '↑/↓ nav/scroll', 'v/space close');
19
+ } else {
20
+ parts.push('↑/↓ navigate', 'enter expand', 'v/space preview');
21
+ }
22
+ }
23
+ if (view === 'runs') {
24
+ if (hasWorktrees) {
25
+ parts.push('b worktrees', 'u others');
26
+ }
27
+ parts.push('a current', 'f files');
28
+ } else if (view === 'git') {
29
+ parts.push('6 status', '7 files', '8 commits', '- diff');
30
+ }
31
+ parts.push(`tab1 ${activeLabel}`);
32
+
33
+ if (availableFlowCount > 1) {
34
+ parts.push('[/] flow');
35
+ }
36
+
37
+ parts.push('r refresh', '? shortcuts', 'q quit');
38
+ return parts.join(' | ');
39
+ }
40
+
41
+ function buildGitCommandStrip(view, options = {}) {
42
+ const {
43
+ hasWorktrees = false,
44
+ previewOpen = false
45
+ } = options;
46
+
47
+ const parts = [];
48
+
49
+ if (view === 'runs') {
50
+ if (hasWorktrees) {
51
+ parts.push('b worktrees');
52
+ }
53
+ parts.push('a current', 'f files', 'enter expand');
54
+ } else if (view === 'intents') {
55
+ parts.push('n next', 'x completed', 'enter expand');
56
+ } else if (view === 'completed') {
57
+ parts.push('c completed', 'enter expand');
58
+ } else if (view === 'health') {
59
+ parts.push('s standards', 't stats', 'w warnings');
60
+ } else if (view === 'git') {
61
+ parts.push('6 status', '7 files', '8 commits', '- diff', 'space preview');
62
+ }
63
+
64
+ if (previewOpen) {
65
+ parts.push('tab pane', 'j/k scroll');
66
+ } else {
67
+ parts.push('v preview');
68
+ }
69
+
70
+ parts.push('1-5 views', 'g/G panels', 'r refresh', '? help', 'q quit');
71
+ return parts.join(' | ');
72
+ }
73
+
74
+ function buildGitCommandLogLine(options = {}) {
75
+ const {
76
+ statusLine = '',
77
+ activeFlow = 'fire',
78
+ watchEnabled = true,
79
+ watchStatus = 'watching',
80
+ selectedWorktreeLabel = null
81
+ } = options;
82
+
83
+ if (typeof statusLine === 'string' && statusLine.trim() !== '') {
84
+ return `Command Log | ${statusLine}`;
85
+ }
86
+
87
+ const watchLabel = watchEnabled ? watchStatus : 'off';
88
+ const worktreeSegment = selectedWorktreeLabel ? ` | wt:${selectedWorktreeLabel}` : '';
89
+ return `Command Log | flow:${String(activeFlow || 'fire').toUpperCase()} | watch:${watchLabel}${worktreeSegment} | ready`;
90
+ }
91
+
92
+ function buildHelpOverlayLines(options = {}) {
93
+ const {
94
+ view = 'runs',
95
+ flow = 'fire',
96
+ previewOpen = false,
97
+ paneFocus = 'main',
98
+ availableFlowCount = 1,
99
+ showErrorSection = false,
100
+ hasWorktrees = false
101
+ } = options;
102
+ const isAidlc = String(flow || '').toLowerCase() === 'aidlc';
103
+ const isSimple = String(flow || '').toLowerCase() === 'simple';
104
+ const itemLabel = isAidlc ? 'bolt' : (isSimple ? 'spec' : 'run');
105
+ const itemPlural = isAidlc ? 'bolts' : (isSimple ? 'specs' : 'runs');
106
+
107
+ const lines = [
108
+ { text: 'Global', color: 'cyan', bold: true },
109
+ 'q or Ctrl+C quit',
110
+ 'r refresh snapshot',
111
+ `1 active ${itemLabel} | 2 intents | 3 completed ${itemPlural} | 4 standards/health | 5 git changes`,
112
+ 'g next section | G previous section',
113
+ 'h/? toggle this shortcuts overlay',
114
+ 'esc close overlays (help/preview/fullscreen)',
115
+ { text: '', color: undefined, bold: false },
116
+ { text: 'Tab 1 Active', color: 'yellow', bold: true },
117
+ ...(hasWorktrees ? ['b focus worktrees section', 'u focus other-worktrees section'] : []),
118
+ `a focus active ${itemLabel}`,
119
+ `f focus ${itemLabel} files`,
120
+ 'up/down or j/k move selection',
121
+ 'enter expand/collapse selected folder row',
122
+ 'v or space preview selected file',
123
+ 'v twice quickly opens fullscreen preview overlay',
124
+ 'tab switch focus between main and preview panes',
125
+ 'o open selected file in system default app'
126
+ ];
127
+
128
+ if (previewOpen) {
129
+ lines.push(`preview is open (focus: ${paneFocus})`);
130
+ }
131
+
132
+ if (availableFlowCount > 1) {
133
+ lines.push('[/] (and m) switch flow');
134
+ }
135
+
136
+ lines.push(
137
+ { text: '', color: undefined, bold: false },
138
+ { text: 'Tab 2 Intents', color: 'green', bold: true },
139
+ 'i focus intents',
140
+ 'n next intents | x completed intents',
141
+ 'left/right toggles next/completed when intents is focused',
142
+ { text: '', color: undefined, bold: false },
143
+ { text: 'Tab 3 Completed', color: 'blue', bold: true },
144
+ 'c focus completed items',
145
+ { text: '', color: undefined, bold: false },
146
+ { text: 'Tab 4 Standards/Health', color: 'magenta', bold: true },
147
+ `s standards | t stats | w warnings${showErrorSection ? ' | e errors' : ''}`,
148
+ { text: '', color: undefined, bold: false },
149
+ { text: 'Tab 5 Git Changes', color: 'yellow', bold: true },
150
+ '7 files: select changed files and preview per-file diffs',
151
+ '8 commits: select a commit to preview the full commit diff',
152
+ '6 status | 7 files | 8 commits | - diff',
153
+ { text: '', color: undefined, bold: false },
154
+ { text: `Current view: ${String(view || 'runs').toUpperCase()}`, color: 'gray', bold: false }
155
+ );
156
+
157
+ return lines;
158
+ }
159
+
160
+ function colorizeMarkdownLine(line, inCodeBlock) {
161
+ const text = String(line ?? '');
162
+
163
+ if (/^\s*```/.test(text)) {
164
+ return {
165
+ color: 'magenta',
166
+ bold: true,
167
+ togglesCodeBlock: true
168
+ };
169
+ }
170
+
171
+ if (/^\s{0,3}#{1,6}\s+/.test(text)) {
172
+ return {
173
+ color: 'cyan',
174
+ bold: true,
175
+ togglesCodeBlock: false
176
+ };
177
+ }
178
+
179
+ if (/^\s*[-*+]\s+\[[ xX]\]/.test(text) || /^\s*[-*+]\s+/.test(text) || /^\s*\d+\.\s+/.test(text)) {
180
+ return {
181
+ color: 'yellow',
182
+ bold: false,
183
+ togglesCodeBlock: false
184
+ };
185
+ }
186
+
187
+ if (/^\s*>\s+/.test(text)) {
188
+ return {
189
+ color: 'gray',
190
+ bold: false,
191
+ togglesCodeBlock: false
192
+ };
193
+ }
194
+
195
+ if (/^\s*---\s*$/.test(text)) {
196
+ return {
197
+ color: 'yellow',
198
+ bold: false,
199
+ togglesCodeBlock: false
200
+ };
201
+ }
202
+
203
+ if (inCodeBlock) {
204
+ return {
205
+ color: 'green',
206
+ bold: false,
207
+ togglesCodeBlock: false
208
+ };
209
+ }
210
+
211
+ return {
212
+ color: undefined,
213
+ bold: false,
214
+ togglesCodeBlock: false
215
+ };
216
+ }
217
+
218
+ function sanitizeRenderLine(value) {
219
+ const raw = String(value ?? '');
220
+ const withoutAnsi = raw
221
+ // OSC sequences (e.g. hyperlinks / title set): ESC ] ... BEL or ESC \
222
+ .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, '')
223
+ // CSI sequences
224
+ .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
225
+ // 7-bit C1 control sequences
226
+ .replace(/\u001B[@-Z\\-_]/g, '')
227
+ // Any remaining ESC bytes
228
+ .replace(/\u001B/g, '')
229
+ // Carriage return can reposition cursor to column 0 and corrupt frame painting
230
+ .replace(/\r/g, '');
231
+
232
+ return withoutAnsi.replace(/[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g, '');
233
+ }
234
+
235
+ module.exports = {
236
+ buildQuickHelpText,
237
+ buildGitCommandStrip,
238
+ buildGitCommandLogLine,
239
+ buildHelpOverlayLines,
240
+ colorizeMarkdownLine,
241
+ sanitizeRenderLine
242
+ };