tlc-claude-code 1.2.26 → 1.2.27

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.
@@ -6,6 +6,11 @@ export interface Task {
6
6
  criteriaDone: number;
7
7
  criteriaTotal: number;
8
8
  }
9
+ export interface TableData {
10
+ headers: string[];
11
+ rows: string[][];
12
+ columnWidths: number[];
13
+ }
9
14
  export interface Phase {
10
15
  number: number;
11
16
  name: string;
@@ -15,6 +20,7 @@ export interface Phase {
15
20
  tasksTotal: number;
16
21
  progress: number;
17
22
  tasks: Task[];
23
+ tables: TableData[];
18
24
  }
19
25
  export interface Milestone {
20
26
  name: string;
@@ -29,4 +35,5 @@ export interface PlanViewProps {
29
35
  export declare function PlanView({ expandedPhase, filter }?: PlanViewProps): import("react/jsx-runtime").JSX.Element;
30
36
  export declare function parseMilestones(content: string): Milestone[];
31
37
  export declare function parsePhases(roadmapContent: string, planContents: Record<number, string>): Phase[];
38
+ export declare function parseTables(content: string): TableData[];
32
39
  export declare function parseTasks(content: string): Task[];
@@ -84,7 +84,23 @@ function PhaseView({ phase, expanded }) {
84
84
  const displayName = phase.name.length > maxNameLength
85
85
  ? phase.name.slice(0, maxNameLength - 1) + '…'
86
86
  : phase.name;
87
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsxs(Text, { color: phase.status === 'in_progress' ? 'cyan' : 'white', children: [phase.number, ". ", displayName] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: phase.progress === 100 ? 'green' : 'gray', children: progressBar }), _jsxs(Text, { color: "gray", children: [" ", taskInfo] }), phase.tasksInProgress > 0 && (_jsxs(Text, { color: "yellow", children: [" (", phase.tasksInProgress, " active)"] }))] }), expanded && phase.tasks.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, marginTop: 0, children: phase.tasks.map(task => (_jsx(TaskView, { task: task }, task.number))) }))] }));
87
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsxs(Text, { color: phase.status === 'in_progress' ? 'cyan' : 'white', children: [phase.number, ". ", displayName] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: phase.progress === 100 ? 'green' : 'gray', children: progressBar }), _jsxs(Text, { color: "gray", children: [" ", taskInfo] }), phase.tasksInProgress > 0 && (_jsxs(Text, { color: "yellow", children: [" (", phase.tasksInProgress, " active)"] }))] }), expanded && (phase.tasks.length > 0 || phase.tables.length > 0) && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 0, children: [phase.tasks.map(task => (_jsx(TaskView, { task: task }, task.number))), phase.tables.map((table, idx) => (_jsx(TableView, { table: table }, idx)))] }))] }));
88
+ }
89
+ function TableView({ table }) {
90
+ const { headers, rows, columnWidths } = table;
91
+ // Render border line
92
+ const renderBorder = (type) => {
93
+ const chars = type === 'top' ? ['┌', '┬', '┐', '─'] :
94
+ type === 'mid' ? ['├', '┼', '┤', '─'] :
95
+ ['└', '┴', '┘', '─'];
96
+ const line = chars[0] + columnWidths.map(w => chars[3].repeat(w + 2)).join(chars[1]) + chars[2];
97
+ return _jsx(Text, { color: "gray", children: line });
98
+ };
99
+ // Render row
100
+ const renderRow = (cells, isHeader = false) => {
101
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "\u2502" }), cells.map((cell, idx) => (_jsxs(Box, { children: [_jsxs(Text, { color: isHeader ? 'cyan' : 'white', bold: isHeader, children: [' ', cell.padEnd(columnWidths[idx]), ' '] }), _jsx(Text, { color: "gray", children: "\u2502" })] }, idx)))] }));
102
+ };
103
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [renderBorder('top'), renderRow(headers, true), renderBorder('mid'), rows.map((row, idx) => (_jsx(Box, { flexDirection: "column", children: renderRow(row) }, idx))), renderBorder('bottom')] }));
88
104
  }
89
105
  function TaskView({ task }) {
90
106
  const statusIcon = task.status === 'completed' ? '✓' :
@@ -217,6 +233,7 @@ export function parsePhases(roadmapContent, planContents) {
217
233
  // Parse tasks from PLAN file if available
218
234
  const planContent = planContents[phaseNum] || '';
219
235
  const tasks = parseTasks(planContent);
236
+ const tables = parseTables(planContent);
220
237
  const tasksDone = tasks.filter(t => t.status === 'completed').length;
221
238
  const tasksInProgress = tasks.filter(t => t.status === 'in_progress').length;
222
239
  const tasksTotal = tasks.length;
@@ -229,12 +246,67 @@ export function parsePhases(roadmapContent, planContents) {
229
246
  tasksInProgress,
230
247
  tasksTotal,
231
248
  progress,
232
- tasks
249
+ tasks,
250
+ tables
233
251
  });
234
252
  }
235
253
  }
236
254
  return phases;
237
255
  }
256
+ export function parseTables(content) {
257
+ const tables = [];
258
+ const lines = content.split('\n');
259
+ let i = 0;
260
+ while (i < lines.length) {
261
+ const line = lines[i];
262
+ // Check if this line looks like a table header row (contains |)
263
+ if (line.includes('|') && line.trim().startsWith('|')) {
264
+ // Check if next line is a separator row (contains |---|)
265
+ const nextLine = lines[i + 1];
266
+ if (nextLine && nextLine.match(/^\|[\s-:|]+\|$/)) {
267
+ // This is a table - parse it
268
+ const headers = parseTableRow(line);
269
+ const rows = [];
270
+ // Skip header and separator
271
+ i += 2;
272
+ // Parse data rows
273
+ while (i < lines.length && lines[i].includes('|') && lines[i].trim().startsWith('|')) {
274
+ const row = parseTableRow(lines[i]);
275
+ if (row.length > 0) {
276
+ rows.push(row);
277
+ }
278
+ i++;
279
+ }
280
+ // Calculate column widths
281
+ const columnWidths = headers.map((h, idx) => {
282
+ const headerWidth = h.length;
283
+ const maxRowWidth = rows.reduce((max, row) => {
284
+ const cellWidth = (row[idx] || '').length;
285
+ return Math.max(max, cellWidth);
286
+ }, 0);
287
+ return Math.max(headerWidth, maxRowWidth, 3);
288
+ });
289
+ tables.push({ headers, rows, columnWidths });
290
+ continue;
291
+ }
292
+ }
293
+ i++;
294
+ }
295
+ return tables;
296
+ }
297
+ function parseTableRow(line) {
298
+ // Remove leading/trailing pipes and split by |
299
+ const trimmed = line.trim();
300
+ if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) {
301
+ return [];
302
+ }
303
+ // Split by | and clean up each cell
304
+ const cells = trimmed
305
+ .slice(1, -1) // Remove outer pipes
306
+ .split('|')
307
+ .map(cell => cell.trim());
308
+ return cells;
309
+ }
238
310
  export function parseTasks(content) {
239
311
  const tasks = [];
240
312
  const lines = content.split('\n');
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { describe, it, expect, vi, beforeEach } from 'vitest';
3
3
  import { render } from 'ink-testing-library';
4
- import { PlanView, parseMilestones, parsePhases, parseTasks } from './PlanView.js';
4
+ import { PlanView, parseMilestones, parsePhases, parseTasks, parseTables } from './PlanView.js';
5
5
  import { vol } from 'memfs';
6
6
  // Mock fs modules
7
7
  vi.mock('fs', async () => {
@@ -171,6 +171,75 @@ Just a simple task`;
171
171
  expect(tasks[0].criteriaTotal).toBe(0);
172
172
  });
173
173
  });
174
+ describe('parseTables', () => {
175
+ it('parses markdown tables', () => {
176
+ const content = `# Phase Info
177
+
178
+ | Feature | Status | Owner |
179
+ |---------|--------|-------|
180
+ | Auth | Done | Alice |
181
+ | API | WIP | Bob |
182
+ `;
183
+ const tables = parseTables(content);
184
+ expect(tables).toHaveLength(1);
185
+ expect(tables[0].headers).toEqual(['Feature', 'Status', 'Owner']);
186
+ expect(tables[0].rows).toHaveLength(2);
187
+ expect(tables[0].rows[0]).toEqual(['Auth', 'Done', 'Alice']);
188
+ expect(tables[0].rows[1]).toEqual(['API', 'WIP', 'Bob']);
189
+ });
190
+ it('calculates column widths', () => {
191
+ const content = `| Short | Very Long Header |
192
+ |-------|------------------|
193
+ | A | B |
194
+ | Longer Cell | C |
195
+ `;
196
+ const tables = parseTables(content);
197
+ expect(tables[0].columnWidths[0]).toBeGreaterThanOrEqual(11); // "Longer Cell"
198
+ expect(tables[0].columnWidths[1]).toBeGreaterThanOrEqual(16); // "Very Long Header"
199
+ });
200
+ it('handles multiple tables', () => {
201
+ const content = `## Table 1
202
+
203
+ | A | B |
204
+ |---|---|
205
+ | 1 | 2 |
206
+
207
+ ## Table 2
208
+
209
+ | X | Y | Z |
210
+ |---|---|---|
211
+ | a | b | c |
212
+ `;
213
+ const tables = parseTables(content);
214
+ expect(tables).toHaveLength(2);
215
+ expect(tables[0].headers).toEqual(['A', 'B']);
216
+ expect(tables[1].headers).toEqual(['X', 'Y', 'Z']);
217
+ });
218
+ it('handles empty content', () => {
219
+ const tables = parseTables('');
220
+ expect(tables).toHaveLength(0);
221
+ });
222
+ it('handles content with no tables', () => {
223
+ const content = `# Just Text
224
+
225
+ Some paragraph here.
226
+
227
+ - List item 1
228
+ - List item 2
229
+ `;
230
+ const tables = parseTables(content);
231
+ expect(tables).toHaveLength(0);
232
+ });
233
+ it('handles tables with alignment markers', () => {
234
+ const content = `| Left | Center | Right |
235
+ |:-----|:------:|------:|
236
+ | A | B | C |
237
+ `;
238
+ const tables = parseTables(content);
239
+ expect(tables).toHaveLength(1);
240
+ expect(tables[0].headers).toEqual(['Left', 'Center', 'Right']);
241
+ });
242
+ });
174
243
  describe('component rendering', () => {
175
244
  it('shows loading state initially', () => {
176
245
  const { lastFrame } = render(_jsx(PlanView, {}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "1.2.26",
3
+ "version": "1.2.27",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
6
  "tlc": "./bin/tlc.js",
@@ -248,6 +248,60 @@
248
248
  color: #e6edf3;
249
249
  margin-right: 10px;
250
250
  }
251
+ .image-drop-zone {
252
+ border: 2px dashed #30363d;
253
+ border-radius: 8px;
254
+ padding: 15px;
255
+ margin-bottom: 10px;
256
+ text-align: center;
257
+ cursor: pointer;
258
+ transition: all 0.2s;
259
+ min-height: 60px;
260
+ }
261
+ .image-drop-zone:hover, .image-drop-zone.dragover {
262
+ border-color: #58a6ff;
263
+ background: rgba(88, 166, 255, 0.1);
264
+ }
265
+ .image-drop-zone .drop-hint {
266
+ color: #8b949e;
267
+ font-size: 13px;
268
+ }
269
+ .image-previews {
270
+ display: flex;
271
+ flex-wrap: wrap;
272
+ gap: 8px;
273
+ margin-top: 10px;
274
+ }
275
+ .image-preview {
276
+ position: relative;
277
+ width: 80px;
278
+ height: 80px;
279
+ border-radius: 6px;
280
+ overflow: hidden;
281
+ border: 1px solid #30363d;
282
+ }
283
+ .image-preview img {
284
+ width: 100%;
285
+ height: 100%;
286
+ object-fit: cover;
287
+ }
288
+ .image-preview .remove-btn {
289
+ position: absolute;
290
+ top: 2px;
291
+ right: 2px;
292
+ width: 20px;
293
+ height: 20px;
294
+ background: rgba(248, 81, 73, 0.9);
295
+ border: none;
296
+ border-radius: 50%;
297
+ color: white;
298
+ cursor: pointer;
299
+ font-size: 12px;
300
+ line-height: 18px;
301
+ }
302
+ .image-preview .remove-btn:hover {
303
+ background: #f85149;
304
+ }
251
305
 
252
306
  /* Logs Panel */
253
307
  .logs-tabs {
@@ -581,6 +635,13 @@
581
635
  <div class="bug-form">
582
636
  <h3>Report New Bug</h3>
583
637
  <textarea id="bug-desc" placeholder="Describe the bug..."></textarea>
638
+ <div class="image-drop-zone" id="image-drop-zone" onclick="document.getElementById('image-input').click()">
639
+ <input type="file" id="image-input" accept="image/*" multiple style="display:none" onchange="handleImageSelect(event)">
640
+ <div class="drop-hint" id="drop-hint">
641
+ 📷 Paste (Ctrl+V) or drop images here
642
+ </div>
643
+ <div class="image-previews" id="image-previews"></div>
644
+ </div>
584
645
  <div>
585
646
  <select id="bug-severity">
586
647
  <option value="low">Low</option>
@@ -589,6 +650,7 @@
589
650
  <option value="critical">Critical</option>
590
651
  </select>
591
652
  <button class="btn btn-primary" onclick="submitBug()">Submit Bug</button>
653
+ <button class="btn" onclick="clearImages()" id="clear-images-btn" style="display:none">Clear Images</button>
592
654
  </div>
593
655
  </div>
594
656
  <div id="bugs-list">
@@ -843,6 +905,93 @@
843
905
  }
844
906
  }
845
907
 
908
+ // Bug image handling
909
+ let bugImages = [];
910
+
911
+ function setupImageHandlers() {
912
+ const dropZone = document.getElementById('image-drop-zone');
913
+
914
+ // Paste handler (global)
915
+ document.addEventListener('paste', (e) => {
916
+ const items = e.clipboardData?.items;
917
+ if (!items) return;
918
+
919
+ for (const item of items) {
920
+ if (item.type.startsWith('image/')) {
921
+ e.preventDefault();
922
+ const file = item.getAsFile();
923
+ if (file) addImage(file);
924
+ }
925
+ }
926
+ });
927
+
928
+ // Drag & drop
929
+ dropZone.addEventListener('dragover', (e) => {
930
+ e.preventDefault();
931
+ dropZone.classList.add('dragover');
932
+ });
933
+ dropZone.addEventListener('dragleave', () => {
934
+ dropZone.classList.remove('dragover');
935
+ });
936
+ dropZone.addEventListener('drop', (e) => {
937
+ e.preventDefault();
938
+ dropZone.classList.remove('dragover');
939
+ const files = e.dataTransfer.files;
940
+ for (const file of files) {
941
+ if (file.type.startsWith('image/')) addImage(file);
942
+ }
943
+ });
944
+ }
945
+
946
+ function handleImageSelect(e) {
947
+ const files = e.target.files;
948
+ for (const file of files) {
949
+ if (file.type.startsWith('image/')) addImage(file);
950
+ }
951
+ e.target.value = ''; // Reset for re-selection
952
+ }
953
+
954
+ function addImage(file) {
955
+ const reader = new FileReader();
956
+ reader.onload = (e) => {
957
+ bugImages.push({ name: file.name, data: e.target.result });
958
+ renderImagePreviews();
959
+ };
960
+ reader.readAsDataURL(file);
961
+ }
962
+
963
+ function removeImage(index) {
964
+ bugImages.splice(index, 1);
965
+ renderImagePreviews();
966
+ }
967
+
968
+ function clearImages() {
969
+ bugImages = [];
970
+ renderImagePreviews();
971
+ }
972
+
973
+ function renderImagePreviews() {
974
+ const container = document.getElementById('image-previews');
975
+ const hint = document.getElementById('drop-hint');
976
+ const clearBtn = document.getElementById('clear-images-btn');
977
+
978
+ if (bugImages.length === 0) {
979
+ container.innerHTML = '';
980
+ hint.style.display = 'block';
981
+ clearBtn.style.display = 'none';
982
+ return;
983
+ }
984
+
985
+ hint.style.display = 'none';
986
+ clearBtn.style.display = 'inline-block';
987
+ container.innerHTML = bugImages.map((img, i) => `
988
+ <div class="image-preview">
989
+ <img src="${img.data}" alt="${img.name}" title="${img.name}">
990
+ <button class="remove-btn" onclick="event.stopPropagation(); removeImage(${i})">×</button>
991
+ </div>
992
+ `).join('');
993
+ }
994
+
846
995
  async function submitBug() {
847
996
  const desc = document.getElementById('bug-desc').value.trim();
848
997
  const severity = document.getElementById('bug-severity').value;
@@ -852,12 +1001,17 @@
852
1001
  const res = await fetch('/api/bug', {
853
1002
  method: 'POST',
854
1003
  headers: { 'Content-Type': 'application/json' },
855
- body: JSON.stringify({ description: desc, severity })
1004
+ body: JSON.stringify({
1005
+ description: desc,
1006
+ severity,
1007
+ images: bugImages.map(img => img.data)
1008
+ })
856
1009
  });
857
1010
  const data = await res.json();
858
1011
  if (data.success) {
859
- alert(`Bug ${data.bugId} created!`);
1012
+ alert(`Bug ${data.bugId} created!` + (bugImages.length ? ` (${bugImages.length} image${bugImages.length > 1 ? 's' : ''} attached)` : ''));
860
1013
  document.getElementById('bug-desc').value = '';
1014
+ clearImages();
861
1015
  refreshBugs();
862
1016
  }
863
1017
  } catch (e) {
@@ -1060,6 +1214,7 @@
1060
1214
  // Initialize
1061
1215
  connect();
1062
1216
  refreshAll();
1217
+ setupImageHandlers();
1063
1218
  setInterval(refreshStats, 30000);
1064
1219
  setInterval(refreshProgress, 30000);
1065
1220
  </script>
package/server/index.js CHANGED
@@ -373,7 +373,7 @@ app.post('/api/playwright', (req, res) => {
373
373
  });
374
374
 
375
375
  app.post('/api/bug', (req, res) => {
376
- const { description, url, screenshot, severity } = req.body;
376
+ const { description, url, screenshot, severity, images } = req.body;
377
377
 
378
378
  if (!description) {
379
379
  return res.status(400).json({ error: 'Description required' });
@@ -386,40 +386,57 @@ app.post('/api/bug', (req, res) => {
386
386
  const nextId = bugs.length + 1;
387
387
  const bugId = `BUG-${String(nextId).padStart(3, '0')}`;
388
388
 
389
+ // Ensure .planning directory exists
390
+ const planningDir = path.join(PROJECT_DIR, '.planning');
391
+ if (!fs.existsSync(planningDir)) {
392
+ fs.mkdirSync(planningDir, { recursive: true });
393
+ }
394
+
395
+ // Handle multiple images (new) or single screenshot (legacy)
396
+ const allImages = images || (screenshot ? [screenshot] : []);
397
+ const savedImages = [];
398
+
399
+ if (allImages.length > 0) {
400
+ const screenshotDir = path.join(planningDir, 'screenshots');
401
+ if (!fs.existsSync(screenshotDir)) {
402
+ fs.mkdirSync(screenshotDir, { recursive: true });
403
+ }
404
+
405
+ allImages.forEach((imgData, index) => {
406
+ if (imgData && imgData.startsWith('data:image')) {
407
+ const ext = imgData.includes('image/png') ? 'png' : 'jpg';
408
+ const filename = allImages.length === 1
409
+ ? `${bugId}.${ext}`
410
+ : `${bugId}-${index + 1}.${ext}`;
411
+ const base64Data = imgData.split(',')[1];
412
+ fs.writeFileSync(
413
+ path.join(screenshotDir, filename),
414
+ Buffer.from(base64Data, 'base64')
415
+ );
416
+ savedImages.push(`screenshots/${filename}`);
417
+ }
418
+ });
419
+ }
420
+
389
421
  // Create bug entry
390
422
  const timestamp = new Date().toISOString().split('T')[0];
423
+ const imagesMarkdown = savedImages.length > 0
424
+ ? `- **Attachments:** ${savedImages.map(img => `![](${img})`).join(' ')}`
425
+ : '';
426
+
391
427
  const bugEntry = `
392
428
  ### ${bugId}: ${description.split('\n')[0].slice(0, 50)} [open]
393
429
 
394
430
  - **Reported:** ${timestamp}
395
431
  - **Severity:** ${severity || 'medium'}
396
432
  - **URL:** ${url || 'N/A'}
397
- ${screenshot ? `- **Screenshot:** screenshots/${bugId}.png` : ''}
433
+ ${imagesMarkdown}
398
434
 
399
435
  ${description}
400
436
 
401
437
  ---
402
438
  `;
403
439
 
404
- // Ensure .planning directory exists
405
- const planningDir = path.join(PROJECT_DIR, '.planning');
406
- if (!fs.existsSync(planningDir)) {
407
- fs.mkdirSync(planningDir, { recursive: true });
408
- }
409
-
410
- // Save screenshot if provided
411
- if (screenshot && screenshot.startsWith('data:image')) {
412
- const screenshotDir = path.join(planningDir, 'screenshots');
413
- if (!fs.existsSync(screenshotDir)) {
414
- fs.mkdirSync(screenshotDir, { recursive: true });
415
- }
416
- const base64Data = screenshot.split(',')[1];
417
- fs.writeFileSync(
418
- path.join(screenshotDir, `${bugId}.png`),
419
- Buffer.from(base64Data, 'base64')
420
- );
421
- }
422
-
423
440
  // Append to BUGS.md
424
441
  let content = '';
425
442
  if (fs.existsSync(bugsFile)) {