tlc-claude-code 1.2.24 → 1.2.26

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/README.md CHANGED
@@ -164,7 +164,7 @@ tlc init
164
164
  |-----|---------|
165
165
  | http://localhost:3147 | Dashboard — Live preview, logs, tasks |
166
166
  | http://localhost:5001 | App — Your running application |
167
- | http://localhost:8080 | DB AdminDatabase GUI (Adminer) |
167
+ | http://localhost:8081 | DB Studiopgweb (or Drizzle/Prisma Studio) |
168
168
  | localhost:5433 | Database — PostgreSQL |
169
169
 
170
170
  **Features:**
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useApp, useInput } from 'ink';
3
3
  import { useState, useCallback } from 'react';
4
4
  import { ChatPane } from './components/ChatPane.js';
5
- import { StatusPane } from './components/StatusPane.js';
5
+ import { PlanView } from './components/PlanView.js';
6
6
  import { PreviewPane } from './components/PreviewPane.js';
7
7
  import { AgentsPane } from './components/AgentsPane.js';
8
8
  import { GitHubPane } from './components/GitHubPane.js';
@@ -17,7 +17,7 @@ export function App({ isTTY = true }) {
17
17
  }
18
18
  if (key.tab) {
19
19
  setActivePane(prev => {
20
- const panes = ['chat', 'github', 'agents', 'preview'];
20
+ const panes = ['chat', 'plan', 'github', 'agents', 'preview'];
21
21
  const idx = panes.indexOf(prev);
22
22
  return panes[(idx + 1) % panes.length];
23
23
  });
@@ -26,10 +26,12 @@ export function App({ isTTY = true }) {
26
26
  if (input === '1')
27
27
  setActivePane('chat');
28
28
  if (input === '2')
29
- setActivePane('github');
29
+ setActivePane('plan');
30
30
  if (input === '3')
31
- setActivePane('agents');
31
+ setActivePane('github');
32
32
  if (input === '4')
33
+ setActivePane('agents');
34
+ if (input === '5')
33
35
  setActivePane('preview');
34
36
  }, { isActive: isTTY });
35
37
  const handleAssignToAgent = useCallback(async (issue) => {
@@ -45,5 +47,5 @@ export function App({ isTTY = true }) {
45
47
  return next;
46
48
  });
47
49
  }, []);
48
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsxs(Box, { borderStyle: "single", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "TDD Dashboard" }), _jsx(Text, { color: "gray", children: " | " }), _jsx(Text, { color: activePane === 'chat' ? 'cyan' : 'gray', children: "[1]Chat " }), _jsx(Text, { color: activePane === 'github' ? 'cyan' : 'gray', children: "[2]GitHub " }), _jsx(Text, { color: activePane === 'agents' ? 'cyan' : 'gray', children: "[3]Agents " }), _jsx(Text, { color: activePane === 'preview' ? 'cyan' : 'gray', children: "[4]Preview" })] }), _jsx(Box, { children: _jsx(Text, { color: "cyan", bold: true, children: "| TDD |" }) })] }), _jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", width: "60%", borderStyle: "single", borderColor: activePane === 'chat' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'chat' ? 'cyan' : 'white', children: "Chat" }) }), _jsx(ChatPane, { isActive: activePane === 'chat', isTTY: isTTY })] }), _jsxs(Box, { flexDirection: "column", width: "40%", children: [_jsxs(Box, { flexDirection: "column", height: "30%", borderStyle: "single", borderColor: activePane === 'github' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'github' ? 'cyan' : 'white', children: "GitHub Issues" }) }), _jsx(GitHubPane, { isActive: activePane === 'github', isTTY: isTTY, onAssignToAgent: handleAssignToAgent })] }), _jsxs(Box, { flexDirection: "column", height: "30%", borderStyle: "single", borderColor: activePane === 'agents' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'agents' ? 'cyan' : 'white', children: "Agents" }) }), _jsx(AgentsPane, { isActive: activePane === 'agents', isTTY: isTTY, onTaskComplete: handleTaskComplete })] }), _jsxs(Box, { flexDirection: "column", height: "20%", borderStyle: "single", borderColor: "gray", children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, children: "Status" }) }), _jsx(StatusPane, {})] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: activePane === 'preview' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'preview' ? 'cyan' : 'white', children: "Preview" }) }), _jsx(PreviewPane, { isActive: activePane === 'preview', isTTY: isTTY })] })] })] }), _jsx(Box, { borderStyle: "single", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Tab: cycle panes | 1-4: jump to pane | Ctrl+Q: quit" }) })] }));
50
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsxs(Box, { borderStyle: "single", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "TLC Dashboard" }), _jsx(Text, { color: "gray", children: " | " }), _jsx(Text, { color: activePane === 'chat' ? 'cyan' : 'gray', children: "[1]Chat " }), _jsx(Text, { color: activePane === 'plan' ? 'cyan' : 'gray', children: "[2]Plan " }), _jsx(Text, { color: activePane === 'github' ? 'cyan' : 'gray', children: "[3]GitHub " }), _jsx(Text, { color: activePane === 'agents' ? 'cyan' : 'gray', children: "[4]Agents " }), _jsx(Text, { color: activePane === 'preview' ? 'cyan' : 'gray', children: "[5]Preview" })] }), _jsx(Box, { children: _jsx(Text, { color: "cyan", bold: true, children: "| TLC |" }) })] }), _jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", width: "60%", borderStyle: "single", borderColor: activePane === 'chat' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'chat' ? 'cyan' : 'white', children: "Chat" }) }), _jsx(ChatPane, { isActive: activePane === 'chat', isTTY: isTTY })] }), _jsxs(Box, { flexDirection: "column", width: "40%", children: [_jsxs(Box, { flexDirection: "column", height: "30%", borderStyle: "single", borderColor: activePane === 'github' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'github' ? 'cyan' : 'white', children: "GitHub Issues" }) }), _jsx(GitHubPane, { isActive: activePane === 'github', isTTY: isTTY, onAssignToAgent: handleAssignToAgent })] }), _jsxs(Box, { flexDirection: "column", height: "30%", borderStyle: "single", borderColor: activePane === 'agents' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'agents' ? 'cyan' : 'white', children: "Agents" }) }), _jsx(AgentsPane, { isActive: activePane === 'agents', isTTY: isTTY, onTaskComplete: handleTaskComplete })] }), _jsxs(Box, { flexDirection: "column", height: "20%", borderStyle: "single", borderColor: activePane === 'plan' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'plan' ? 'cyan' : 'white', children: "Plan" }) }), _jsx(PlanView, {})] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: activePane === 'preview' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'preview' ? 'cyan' : 'white', children: "Preview" }) }), _jsx(PreviewPane, { isActive: activePane === 'preview', isTTY: isTTY })] })] })] }), _jsx(Box, { borderStyle: "single", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Tab: cycle panes | 1-5: jump to pane | Ctrl+Q: quit" }) })] }));
49
51
  }
@@ -44,18 +44,19 @@ describe('App', () => {
44
44
  const { lastFrame } = render(_jsx(App, {}));
45
45
  expect(lastFrame()).toBeDefined();
46
46
  });
47
- it('shows TDD Dashboard header', () => {
47
+ it('shows TLC Dashboard header', () => {
48
48
  const { lastFrame } = render(_jsx(App, {}));
49
49
  const output = lastFrame();
50
- expect(output).toContain('TDD Dashboard');
50
+ expect(output).toContain('TLC Dashboard');
51
51
  });
52
52
  it('shows all pane labels in header', () => {
53
53
  const { lastFrame } = render(_jsx(App, {}));
54
54
  const output = lastFrame();
55
55
  expect(output).toContain('[1]Chat');
56
- expect(output).toContain('[2]GitHub');
57
- expect(output).toContain('[3]Agents');
58
- expect(output).toContain('[4]Preview');
56
+ expect(output).toContain('[2]Plan');
57
+ expect(output).toContain('[3]GitHub');
58
+ expect(output).toContain('[4]Agents');
59
+ expect(output).toContain('[5]Preview');
59
60
  });
60
61
  it('shows footer with keyboard hints', () => {
61
62
  const { lastFrame } = render(_jsx(App, {}));
@@ -80,10 +81,10 @@ describe('App', () => {
80
81
  const output = lastFrame();
81
82
  expect(output).toContain('Agents');
82
83
  });
83
- it('shows Status pane', () => {
84
+ it('shows Plan pane', () => {
84
85
  const { lastFrame } = render(_jsx(App, {}));
85
86
  const output = lastFrame();
86
- expect(output).toContain('Status');
87
+ expect(output).toContain('Plan');
87
88
  });
88
89
  it('shows Preview pane', () => {
89
90
  const { lastFrame } = render(_jsx(App, {}));
@@ -92,23 +93,29 @@ describe('App', () => {
92
93
  });
93
94
  });
94
95
  describe('keyboard navigation', () => {
95
- it('switches to github pane when pressing 2', () => {
96
+ it('switches to plan pane when pressing 2', () => {
96
97
  const { lastFrame, stdin } = render(_jsx(App, {}));
97
98
  stdin.write('2');
98
99
  const output = lastFrame();
99
- // GitHub pane should now be active (shown by highlighting)
100
- expect(output).toContain('GitHub');
100
+ // Plan pane should now be active (shown by highlighting)
101
+ expect(output).toContain('Plan');
101
102
  });
102
- it('switches to agents pane when pressing 3', () => {
103
+ it('switches to github pane when pressing 3', () => {
103
104
  const { lastFrame, stdin } = render(_jsx(App, {}));
104
105
  stdin.write('3');
105
106
  const output = lastFrame();
106
- expect(output).toContain('Agents');
107
+ expect(output).toContain('GitHub');
107
108
  });
108
- it('switches to preview pane when pressing 4', () => {
109
+ it('switches to agents pane when pressing 4', () => {
109
110
  const { lastFrame, stdin } = render(_jsx(App, {}));
110
111
  stdin.write('4');
111
112
  const output = lastFrame();
113
+ expect(output).toContain('Agents');
114
+ });
115
+ it('switches to preview pane when pressing 5', () => {
116
+ const { lastFrame, stdin } = render(_jsx(App, {}));
117
+ stdin.write('5');
118
+ const output = lastFrame();
112
119
  expect(output).toContain('Preview');
113
120
  });
114
121
  it('switches back to chat pane when pressing 1', () => {
@@ -127,11 +134,11 @@ describe('App', () => {
127
134
  expect(output).toBeDefined();
128
135
  });
129
136
  });
130
- describe('TDD branding', () => {
131
- it('shows TDD indicator in header', () => {
137
+ describe('TLC branding', () => {
138
+ it('shows TLC indicator in header', () => {
132
139
  const { lastFrame } = render(_jsx(App, {}));
133
140
  const output = lastFrame();
134
- expect(output).toContain('TDD');
141
+ expect(output).toContain('TLC');
135
142
  });
136
143
  });
137
144
  });
@@ -0,0 +1,32 @@
1
+ export interface Task {
2
+ number: number;
3
+ name: string;
4
+ status: 'completed' | 'in_progress' | 'pending';
5
+ owner?: string;
6
+ criteriaDone: number;
7
+ criteriaTotal: number;
8
+ }
9
+ export interface Phase {
10
+ number: number;
11
+ name: string;
12
+ status: 'completed' | 'in_progress' | 'pending';
13
+ tasksDone: number;
14
+ tasksInProgress: number;
15
+ tasksTotal: number;
16
+ progress: number;
17
+ tasks: Task[];
18
+ }
19
+ export interface Milestone {
20
+ name: string;
21
+ status: 'completed' | 'in_progress' | 'pending';
22
+ phaseNumbers: number[];
23
+ phases: Phase[];
24
+ }
25
+ export interface PlanViewProps {
26
+ expandedPhase?: number;
27
+ filter?: 'all' | 'in_progress' | 'pending' | 'completed';
28
+ }
29
+ export declare function PlanView({ expandedPhase, filter }?: PlanViewProps): import("react/jsx-runtime").JSX.Element;
30
+ export declare function parseMilestones(content: string): Milestone[];
31
+ export declare function parsePhases(roadmapContent: string, planContents: Record<number, string>): Phase[];
32
+ export declare function parseTasks(content: string): Task[];
@@ -0,0 +1,292 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useState, useEffect } from 'react';
4
+ import { readFile, readdir } from 'fs/promises';
5
+ import { existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ export function PlanView({ expandedPhase, filter = 'all' } = {}) {
8
+ const [milestones, setMilestones] = useState([]);
9
+ const [loading, setLoading] = useState(true);
10
+ const [collapsedMilestones, setCollapsedMilestones] = useState(new Set());
11
+ useEffect(() => {
12
+ async function loadPlan() {
13
+ const roadmapPath = join(process.cwd(), '.planning', 'ROADMAP.md');
14
+ if (!existsSync(roadmapPath)) {
15
+ setMilestones([]);
16
+ setLoading(false);
17
+ return;
18
+ }
19
+ try {
20
+ const content = await readFile(roadmapPath, 'utf-8');
21
+ const planContents = await loadPlanFiles();
22
+ const parsedMilestones = parseMilestones(content);
23
+ const phases = parsePhases(content, planContents);
24
+ // Attach phases to milestones
25
+ const milestonesWithPhases = parsedMilestones.map(m => ({
26
+ ...m,
27
+ phases: phases.filter(p => m.phaseNumbers.includes(p.number))
28
+ }));
29
+ // Auto-collapse completed milestones (but not if there's only one milestone)
30
+ const completed = new Set();
31
+ if (milestonesWithPhases.length > 1) {
32
+ milestonesWithPhases.forEach(m => {
33
+ if (m.status === 'completed') {
34
+ completed.add(m.name);
35
+ }
36
+ });
37
+ }
38
+ setCollapsedMilestones(completed);
39
+ setMilestones(milestonesWithPhases);
40
+ }
41
+ catch (e) {
42
+ setMilestones([]);
43
+ }
44
+ setLoading(false);
45
+ }
46
+ loadPlan();
47
+ const interval = setInterval(loadPlan, 5000);
48
+ return () => clearInterval(interval);
49
+ }, []);
50
+ if (loading) {
51
+ return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "gray", children: "Loading..." }) }));
52
+ }
53
+ if (milestones.length === 0) {
54
+ return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No roadmap found." }), _jsx(Text, { color: "gray", dimColor: true, children: "Run /tlc:new-project or /tlc:init" })] }));
55
+ }
56
+ return (_jsx(Box, { padding: 1, flexDirection: "column", children: milestones.map((milestone, idx) => (_jsx(MilestoneView, { milestone: milestone, collapsed: collapsedMilestones.has(milestone.name), expandedPhase: expandedPhase, filter: filter }, milestone.name))) }));
57
+ }
58
+ function MilestoneView({ milestone, collapsed, expandedPhase, filter }) {
59
+ const statusIcon = milestone.status === 'completed' ? '✓' :
60
+ milestone.status === 'in_progress' ? '▶' : '○';
61
+ const statusColor = milestone.status === 'completed' ? 'green' :
62
+ milestone.status === 'in_progress' ? 'yellow' : 'gray';
63
+ const completedPhases = milestone.phases.filter(p => p.status === 'completed').length;
64
+ const totalPhases = milestone.phases.length;
65
+ const progress = totalPhases > 0 ? Math.round((completedPhases / totalPhases) * 100) : 0;
66
+ const filteredPhases = milestone.phases.filter(p => {
67
+ if (filter === 'all')
68
+ return true;
69
+ return p.status === filter;
70
+ });
71
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsx(Text, { bold: true, color: milestone.status === 'in_progress' ? 'cyan' : 'white', children: milestone.name }), _jsxs(Text, { color: "gray", children: [" (", completedPhases, "/", totalPhases, " phases"] }), progress > 0 && _jsxs(Text, { color: "gray", children: [" \u00B7 ", progress, "%"] }), _jsx(Text, { color: "gray", children: ")" }), collapsed && _jsx(Text, { color: "gray", dimColor: true, children: " [collapsed]" })] }), !collapsed && filteredPhases.map(phase => (_jsx(PhaseView, { phase: phase, expanded: expandedPhase === phase.number }, phase.number)))] }));
72
+ }
73
+ function PhaseView({ phase, expanded }) {
74
+ const statusIcon = phase.status === 'completed' ? '[x]' :
75
+ phase.status === 'in_progress' ? '[>]' : '[ ]';
76
+ const statusColor = phase.status === 'completed' ? 'green' :
77
+ phase.status === 'in_progress' ? 'yellow' : 'gray';
78
+ const progressBar = renderProgressBar(phase.progress, 10);
79
+ const taskInfo = phase.tasksTotal > 0
80
+ ? `${phase.tasksDone}/${phase.tasksTotal}`
81
+ : '0/0';
82
+ // Truncate long phase names
83
+ const maxNameLength = 30;
84
+ const displayName = phase.name.length > maxNameLength
85
+ ? phase.name.slice(0, maxNameLength - 1) + '…'
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))) }))] }));
88
+ }
89
+ function TaskView({ task }) {
90
+ const statusIcon = task.status === 'completed' ? '✓' :
91
+ task.status === 'in_progress' ? '▶' : '○';
92
+ const statusColor = task.status === 'completed' ? 'green' :
93
+ task.status === 'in_progress' ? 'yellow' : 'gray';
94
+ const criteriaInfo = task.criteriaTotal > 0
95
+ ? ` (${task.criteriaDone}/${task.criteriaTotal} criteria)`
96
+ : '';
97
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsx(Text, { color: task.status === 'in_progress' ? 'cyan' : 'white', children: task.name }), task.owner && _jsxs(Text, { color: "magenta", children: [" @", task.owner] }), criteriaInfo && _jsx(Text, { color: "gray", children: criteriaInfo })] }));
98
+ }
99
+ function renderProgressBar(percent, width) {
100
+ const filled = Math.round((percent / 100) * width);
101
+ const empty = width - filled;
102
+ return '█'.repeat(filled) + '░'.repeat(empty);
103
+ }
104
+ async function loadPlanFiles() {
105
+ const phasesDir = join(process.cwd(), '.planning', 'phases');
106
+ const planContents = {};
107
+ if (!existsSync(phasesDir)) {
108
+ return planContents;
109
+ }
110
+ try {
111
+ const entries = await readdir(phasesDir, { withFileTypes: true });
112
+ for (const entry of entries) {
113
+ if (entry.isDirectory()) {
114
+ // Extract phase number from directory name (e.g., "01-core" -> 1)
115
+ const match = entry.name.match(/^(\d+)/);
116
+ if (match) {
117
+ const phaseNum = parseInt(match[1], 10);
118
+ const planPath = join(phasesDir, entry.name, `${match[1]}-PLAN.md`);
119
+ if (existsSync(planPath)) {
120
+ try {
121
+ planContents[phaseNum] = await readFile(planPath, 'utf-8');
122
+ }
123
+ catch (e) {
124
+ // Skip unreadable files
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ catch (e) {
132
+ // Return empty if can't read directory
133
+ }
134
+ return planContents;
135
+ }
136
+ export function parseMilestones(content) {
137
+ const milestones = [];
138
+ const lines = content.split('\n');
139
+ let currentMilestone = null;
140
+ let hasExplicitMilestones = false;
141
+ // Track phase statuses per milestone for status calculation
142
+ const milestonePhaseStatuses = new Map();
143
+ let milestoneIndex = -1;
144
+ for (const line of lines) {
145
+ // Match milestone headers like "## Milestone: v1.0 - Release" or "## Milestone: v1.0 [x]"
146
+ const milestoneMatch = line.match(/^##\s*Milestone:\s*(.+?)(?:\s*\[([x>]?)\])?\s*$/i);
147
+ if (milestoneMatch) {
148
+ hasExplicitMilestones = true;
149
+ if (currentMilestone) {
150
+ milestones.push(currentMilestone);
151
+ }
152
+ milestoneIndex++;
153
+ milestonePhaseStatuses.set(milestoneIndex, []);
154
+ currentMilestone = {
155
+ name: milestoneMatch[1].trim(),
156
+ status: milestoneMatch[2] === 'x' ? 'completed' : 'pending',
157
+ phaseNumbers: [],
158
+ phases: []
159
+ };
160
+ continue;
161
+ }
162
+ // Match phase headers like "### Phase 1: Setup [x]"
163
+ const phaseMatch = line.match(/^###\s*(?:Phase\s+)?(\d+)[.:]?\s*(.+?)(?:\s*\[([x>]?)\])?\s*$/i);
164
+ if (phaseMatch) {
165
+ const phaseNum = parseInt(phaseMatch[1], 10);
166
+ const phaseStatus = phaseMatch[3] === 'x' ? 'completed' :
167
+ phaseMatch[3] === '>' ? 'in_progress' : 'pending';
168
+ if (currentMilestone) {
169
+ currentMilestone.phaseNumbers.push(phaseNum);
170
+ milestonePhaseStatuses.get(milestoneIndex)?.push(phaseStatus);
171
+ }
172
+ else if (!hasExplicitMilestones) {
173
+ // Create implicit "Current" milestone for roadmaps without explicit milestones
174
+ milestoneIndex = 0;
175
+ milestonePhaseStatuses.set(milestoneIndex, [phaseStatus]);
176
+ currentMilestone = {
177
+ name: 'Current',
178
+ status: 'pending',
179
+ phaseNumbers: [phaseNum],
180
+ phases: []
181
+ };
182
+ }
183
+ }
184
+ }
185
+ // Add the last milestone
186
+ if (currentMilestone) {
187
+ milestones.push(currentMilestone);
188
+ }
189
+ // Determine milestone statuses from their phases
190
+ milestones.forEach((milestone, idx) => {
191
+ const statuses = milestonePhaseStatuses.get(idx) || [];
192
+ if (statuses.length === 0) {
193
+ milestone.status = 'pending';
194
+ }
195
+ else if (statuses.every(s => s === 'completed')) {
196
+ milestone.status = 'completed';
197
+ }
198
+ else if (statuses.some(s => s === 'in_progress')) {
199
+ milestone.status = 'in_progress';
200
+ }
201
+ else {
202
+ milestone.status = 'pending';
203
+ }
204
+ });
205
+ return milestones;
206
+ }
207
+ export function parsePhases(roadmapContent, planContents) {
208
+ const phases = [];
209
+ const lines = roadmapContent.split('\n');
210
+ for (const line of lines) {
211
+ const match = line.match(/^###\s*(?:Phase\s+)?(\d+)[.:]?\s*(.+?)(?:\s*\[([x>]?)\])?\s*$/i);
212
+ if (match) {
213
+ const phaseNum = parseInt(match[1], 10);
214
+ const phaseName = match[2].replace(/\s*\[.*?\]\s*$/, '').trim();
215
+ const status = match[3] === 'x' ? 'completed' :
216
+ match[3] === '>' ? 'in_progress' : 'pending';
217
+ // Parse tasks from PLAN file if available
218
+ const planContent = planContents[phaseNum] || '';
219
+ const tasks = parseTasks(planContent);
220
+ const tasksDone = tasks.filter(t => t.status === 'completed').length;
221
+ const tasksInProgress = tasks.filter(t => t.status === 'in_progress').length;
222
+ const tasksTotal = tasks.length;
223
+ const progress = tasksTotal > 0 ? Math.round((tasksDone / tasksTotal) * 100) : 0;
224
+ phases.push({
225
+ number: phaseNum,
226
+ name: phaseName,
227
+ status,
228
+ tasksDone,
229
+ tasksInProgress,
230
+ tasksTotal,
231
+ progress,
232
+ tasks
233
+ });
234
+ }
235
+ }
236
+ return phases;
237
+ }
238
+ export function parseTasks(content) {
239
+ const tasks = [];
240
+ const lines = content.split('\n');
241
+ let currentTask = null;
242
+ let inAcceptanceCriteria = false;
243
+ for (let i = 0; i < lines.length; i++) {
244
+ const line = lines[i];
245
+ // Match task headers like "### Task 1: Setup Project [x@alice]" or "### Task 1: Name [ ]"
246
+ // Also match without brackets for incomplete lines
247
+ const taskMatch = line.match(/^###\s*Task\s+(\d+)[.:]?\s*(.+?)\s*\[([x> ]?)(?:@(\w+))?\]\s*$/i);
248
+ if (taskMatch) {
249
+ // Save previous task
250
+ if (currentTask) {
251
+ tasks.push(currentTask);
252
+ }
253
+ const statusChar = taskMatch[3].trim();
254
+ const status = statusChar === 'x' ? 'completed' :
255
+ statusChar === '>' ? 'in_progress' : 'pending';
256
+ currentTask = {
257
+ number: parseInt(taskMatch[1], 10),
258
+ name: taskMatch[2].trim(),
259
+ status,
260
+ owner: taskMatch[4] || undefined,
261
+ criteriaDone: 0,
262
+ criteriaTotal: 0
263
+ };
264
+ inAcceptanceCriteria = false;
265
+ continue;
266
+ }
267
+ // Track when we enter acceptance criteria section
268
+ if (line.match(/\*\*Acceptance Criteria\*\*|Acceptance Criteria:/i)) {
269
+ inAcceptanceCriteria = true;
270
+ continue;
271
+ }
272
+ // Count acceptance criteria checkboxes
273
+ if (currentTask && (inAcceptanceCriteria || line.match(/^-\s*\[[ x]\]/))) {
274
+ const checkboxMatch = line.match(/^-\s*\[([ x])\]/);
275
+ if (checkboxMatch) {
276
+ currentTask.criteriaTotal++;
277
+ if (checkboxMatch[1] === 'x') {
278
+ currentTask.criteriaDone++;
279
+ }
280
+ }
281
+ }
282
+ // Reset criteria tracking on new section
283
+ if (line.match(/^###\s/) && !line.match(/^###\s*Task/i)) {
284
+ inAcceptanceCriteria = false;
285
+ }
286
+ }
287
+ // Don't forget the last task
288
+ if (currentTask) {
289
+ tasks.push(currentTask);
290
+ }
291
+ return tasks;
292
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,335 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import { PlanView, parseMilestones, parsePhases, parseTasks } from './PlanView.js';
5
+ import { vol } from 'memfs';
6
+ // Mock fs modules
7
+ vi.mock('fs', async () => {
8
+ const memfs = await import('memfs');
9
+ return memfs.fs;
10
+ });
11
+ vi.mock('fs/promises', async () => {
12
+ const memfs = await import('memfs');
13
+ return memfs.fs.promises;
14
+ });
15
+ describe('PlanView', () => {
16
+ beforeEach(() => {
17
+ vol.reset();
18
+ });
19
+ describe('parseMilestones', () => {
20
+ it('extracts milestones from roadmap', () => {
21
+ const content = `# Roadmap
22
+
23
+ ## Milestone: v1.0 - Initial Release
24
+
25
+ ### Phase 1: Setup [x]
26
+ ### Phase 2: Core [>]
27
+
28
+ ## Milestone: v1.1 - Improvements
29
+
30
+ ### Phase 3: Polish
31
+ ### Phase 4: Docs`;
32
+ const milestones = parseMilestones(content);
33
+ expect(milestones).toHaveLength(2);
34
+ expect(milestones[0].name).toBe('v1.0 - Initial Release');
35
+ expect(milestones[0].phaseNumbers).toEqual([1, 2]);
36
+ expect(milestones[1].name).toBe('v1.1 - Improvements');
37
+ expect(milestones[1].phaseNumbers).toEqual([3, 4]);
38
+ });
39
+ it('handles roadmap with no explicit milestones', () => {
40
+ const content = `# Roadmap
41
+
42
+ ### Phase 1: Setup [x]
43
+ ### Phase 2: Core`;
44
+ const milestones = parseMilestones(content);
45
+ expect(milestones).toHaveLength(1);
46
+ expect(milestones[0].name).toBe('Current');
47
+ expect(milestones[0].phaseNumbers).toEqual([1, 2]);
48
+ });
49
+ it('detects milestone status from phase statuses', () => {
50
+ const content = `## Milestone: v1.0
51
+
52
+ ### Phase 1: Setup [x]
53
+ ### Phase 2: Core [x]
54
+
55
+ ## Milestone: v1.1
56
+
57
+ ### Phase 3: Polish [>]
58
+ ### Phase 4: Docs`;
59
+ const milestones = parseMilestones(content);
60
+ expect(milestones[0].status).toBe('completed');
61
+ expect(milestones[1].status).toBe('in_progress');
62
+ });
63
+ it('marks milestone as pending if no phases started', () => {
64
+ const content = `## Milestone: v1.0
65
+
66
+ ### Phase 1: Setup [x]
67
+
68
+ ## Milestone: v1.1
69
+
70
+ ### Phase 2: Core
71
+ ### Phase 3: Docs`;
72
+ const milestones = parseMilestones(content);
73
+ expect(milestones[0].status).toBe('completed');
74
+ expect(milestones[1].status).toBe('pending');
75
+ });
76
+ });
77
+ describe('parsePhases', () => {
78
+ it('parses phases with task counts from PLAN files', () => {
79
+ const roadmapContent = `### Phase 1: Setup [x]
80
+ ### Phase 2: Core [>]`;
81
+ const planContents = {
82
+ 1: `# Phase 1
83
+ ### Task 1: Init [x@alice]
84
+ ### Task 2: Config [x@bob]`,
85
+ 2: `# Phase 2
86
+ ### Task 1: API [>@alice]
87
+ ### Task 2: DB [ ]
88
+ ### Task 3: Auth [ ]`
89
+ };
90
+ const phases = parsePhases(roadmapContent, planContents);
91
+ expect(phases).toHaveLength(2);
92
+ expect(phases[0].tasksDone).toBe(2);
93
+ expect(phases[0].tasksTotal).toBe(2);
94
+ expect(phases[1].tasksDone).toBe(0);
95
+ expect(phases[1].tasksTotal).toBe(3);
96
+ expect(phases[1].tasksInProgress).toBe(1);
97
+ });
98
+ it('calculates progress percentage', () => {
99
+ const roadmapContent = `### Phase 1: Core`;
100
+ const planContents = {
101
+ 1: `### Task 1: A [x@u]
102
+ ### Task 2: B [x@u]
103
+ ### Task 3: C [ ]
104
+ ### Task 4: D [ ]`
105
+ };
106
+ const phases = parsePhases(roadmapContent, planContents);
107
+ expect(phases[0].progress).toBe(50);
108
+ });
109
+ it('handles phases with no PLAN file', () => {
110
+ const roadmapContent = `### Phase 1: Setup [x]
111
+ ### Phase 2: Planned`;
112
+ const planContents = {
113
+ 1: `### Task 1: Done [x@u]`
114
+ };
115
+ const phases = parsePhases(roadmapContent, planContents);
116
+ expect(phases).toHaveLength(2);
117
+ expect(phases[1].tasksTotal).toBe(0);
118
+ expect(phases[1].progress).toBe(0);
119
+ });
120
+ });
121
+ describe('parseTasks', () => {
122
+ it('extracts tasks from PLAN content', () => {
123
+ const content = `# Phase 1
124
+
125
+ ### Task 1: Setup Project [x@alice]
126
+ Some description
127
+
128
+ ### Task 2: Add Config [>@bob]
129
+ Working on it
130
+
131
+ ### Task 3: Write Tests [ ]
132
+ Not started`;
133
+ const tasks = parseTasks(content);
134
+ expect(tasks).toHaveLength(3);
135
+ expect(tasks[0]).toMatchObject({
136
+ number: 1,
137
+ name: 'Setup Project',
138
+ status: 'completed',
139
+ owner: 'alice'
140
+ });
141
+ expect(tasks[1]).toMatchObject({
142
+ number: 2,
143
+ name: 'Add Config',
144
+ status: 'in_progress',
145
+ owner: 'bob'
146
+ });
147
+ expect(tasks[2]).toMatchObject({
148
+ number: 3,
149
+ name: 'Write Tests',
150
+ status: 'pending',
151
+ owner: undefined
152
+ });
153
+ });
154
+ it('parses acceptance criteria counts', () => {
155
+ const content = `### Task 1: Feature [>@dev]
156
+
157
+ **Acceptance Criteria:**
158
+ - [x] First item done
159
+ - [x] Second item done
160
+ - [ ] Third item pending
161
+ - [ ] Fourth item pending`;
162
+ const tasks = parseTasks(content);
163
+ expect(tasks[0].criteriaDone).toBe(2);
164
+ expect(tasks[0].criteriaTotal).toBe(4);
165
+ });
166
+ it('handles tasks without acceptance criteria', () => {
167
+ const content = `### Task 1: Simple [x@dev]
168
+ Just a simple task`;
169
+ const tasks = parseTasks(content);
170
+ expect(tasks[0].criteriaDone).toBe(0);
171
+ expect(tasks[0].criteriaTotal).toBe(0);
172
+ });
173
+ });
174
+ describe('component rendering', () => {
175
+ it('shows loading state initially', () => {
176
+ const { lastFrame } = render(_jsx(PlanView, {}));
177
+ expect(lastFrame()).toContain('Loading');
178
+ });
179
+ it('shows empty state when no roadmap', async () => {
180
+ const { lastFrame } = render(_jsx(PlanView, {}));
181
+ await new Promise(resolve => setTimeout(resolve, 100));
182
+ expect(lastFrame()).toContain('No roadmap found');
183
+ });
184
+ it('renders milestones with phases', async () => {
185
+ vol.fromJSON({
186
+ [process.cwd() + '/.planning/ROADMAP.md']: `# Roadmap
187
+
188
+ ## Milestone: v1.0 - Release
189
+
190
+ ### Phase 1: Setup [x]
191
+ ### Phase 2: Core [>]
192
+ ### Phase 3: Polish`
193
+ });
194
+ const { lastFrame } = render(_jsx(PlanView, {}));
195
+ await new Promise(resolve => setTimeout(resolve, 100));
196
+ const output = lastFrame();
197
+ expect(output).toContain('v1.0');
198
+ expect(output).toContain('Setup');
199
+ expect(output).toContain('Core');
200
+ expect(output).toContain('Polish');
201
+ });
202
+ it('shows progress bar for phases', async () => {
203
+ vol.fromJSON({
204
+ [process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: Core [>]`,
205
+ [process.cwd() + '/.planning/phases/01-core/01-PLAN.md']: `
206
+ ### Task 1: A [x@u]
207
+ ### Task 2: B [x@u]
208
+ ### Task 3: C [ ]
209
+ ### Task 4: D [ ]`
210
+ });
211
+ const { lastFrame } = render(_jsx(PlanView, {}));
212
+ await new Promise(resolve => setTimeout(resolve, 100));
213
+ const output = lastFrame();
214
+ // Should show 2/4 or 50% progress indicator
215
+ expect(output).toMatch(/2\/4|50%|████/);
216
+ });
217
+ it('shows task counts per phase', async () => {
218
+ vol.fromJSON({
219
+ [process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: Auth [x]`,
220
+ [process.cwd() + '/.planning/phases/01-auth/01-PLAN.md']: `
221
+ ### Task 1: Login [x@a]
222
+ ### Task 2: Logout [x@b]
223
+ ### Task 3: Session [x@a]`
224
+ });
225
+ const { lastFrame } = render(_jsx(PlanView, {}));
226
+ await new Promise(resolve => setTimeout(resolve, 100));
227
+ const output = lastFrame();
228
+ expect(output).toContain('3/3');
229
+ });
230
+ it('collapses completed milestones by default', async () => {
231
+ vol.fromJSON({
232
+ [process.cwd() + '/.planning/ROADMAP.md']: `
233
+ ## Milestone: v0.9 [x]
234
+
235
+ ### Phase 1: Old [x]
236
+
237
+ ## Milestone: v1.0
238
+
239
+ ### Phase 2: Current [>]`
240
+ });
241
+ const { lastFrame } = render(_jsx(PlanView, {}));
242
+ await new Promise(resolve => setTimeout(resolve, 100));
243
+ const output = lastFrame();
244
+ // v0.9 should be collapsed (not showing "Old")
245
+ expect(output).toContain('v0.9');
246
+ expect(output).toContain('Current');
247
+ // Old phase should not be visible (collapsed)
248
+ expect(output).not.toMatch(/Old\s+\[/);
249
+ });
250
+ it('expands phase to show tasks when selected', async () => {
251
+ vol.fromJSON({
252
+ [process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: Core [>]`,
253
+ [process.cwd() + '/.planning/phases/01-core/01-PLAN.md']: `
254
+ ### Task 1: API [x@alice]
255
+ ### Task 2: DB [>@bob]`
256
+ });
257
+ const { lastFrame } = render(_jsx(PlanView, { expandedPhase: 1 }));
258
+ await new Promise(resolve => setTimeout(resolve, 100));
259
+ const output = lastFrame();
260
+ expect(output).toContain('API');
261
+ expect(output).toContain('alice');
262
+ expect(output).toContain('DB');
263
+ expect(output).toContain('bob');
264
+ });
265
+ it('shows filter options', async () => {
266
+ vol.fromJSON({
267
+ [process.cwd() + '/.planning/ROADMAP.md']: `
268
+ ### Phase 1: Done [x]
269
+ ### Phase 2: Active [>]
270
+ ### Phase 3: Pending`
271
+ });
272
+ const { lastFrame } = render(_jsx(PlanView, { filter: "in_progress" }));
273
+ await new Promise(resolve => setTimeout(resolve, 100));
274
+ const output = lastFrame();
275
+ expect(output).toContain('Active');
276
+ expect(output).not.toContain('Done');
277
+ expect(output).not.toContain('Pending');
278
+ });
279
+ it('shows overall milestone progress', async () => {
280
+ vol.fromJSON({
281
+ [process.cwd() + '/.planning/ROADMAP.md']: `
282
+ ## Milestone: v1.0
283
+
284
+ ### Phase 1: A [x]
285
+ ### Phase 2: B [x]
286
+ ### Phase 3: C [>]
287
+ ### Phase 4: D`
288
+ });
289
+ const { lastFrame } = render(_jsx(PlanView, {}));
290
+ await new Promise(resolve => setTimeout(resolve, 100));
291
+ const output = lastFrame();
292
+ // Should show 2/4 phases or 50% for milestone
293
+ expect(output).toMatch(/2\/4|50%/);
294
+ });
295
+ });
296
+ describe('edge cases', () => {
297
+ it('handles malformed PLAN files gracefully', async () => {
298
+ vol.fromJSON({
299
+ [process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: Test`,
300
+ [process.cwd() + '/.planning/phases/01-test/01-PLAN.md']: `
301
+ This is not a proper plan file
302
+ No tasks here
303
+ Just random text`
304
+ });
305
+ const { lastFrame } = render(_jsx(PlanView, {}));
306
+ await new Promise(resolve => setTimeout(resolve, 100));
307
+ const output = lastFrame();
308
+ expect(output).toContain('Test');
309
+ expect(output).toContain('0/0');
310
+ });
311
+ it('handles very long phase names', async () => {
312
+ vol.fromJSON({
313
+ [process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: This Is A Very Long Phase Name That Should Be Truncated [>]`
314
+ });
315
+ const { lastFrame } = render(_jsx(PlanView, {}));
316
+ await new Promise(resolve => setTimeout(resolve, 100));
317
+ // Should not crash, should render something
318
+ expect(lastFrame()).toBeTruthy();
319
+ });
320
+ it('handles phases numbered non-sequentially', async () => {
321
+ vol.fromJSON({
322
+ [process.cwd() + '/.planning/ROADMAP.md']: `
323
+ ### Phase 1: First [x]
324
+ ### Phase 3: Third [>]
325
+ ### Phase 5: Fifth`
326
+ });
327
+ const { lastFrame } = render(_jsx(PlanView, {}));
328
+ await new Promise(resolve => setTimeout(resolve, 100));
329
+ const output = lastFrame();
330
+ expect(output).toContain('First');
331
+ expect(output).toContain('Third');
332
+ expect(output).toContain('Fifth');
333
+ });
334
+ });
335
+ });
@@ -58,17 +58,20 @@ services:
58
58
  condition: service_healthy
59
59
  restart: on-failure
60
60
 
61
- # Database GUI (Adminer)
61
+ # Database GUI (pgweb - clean, modern)
62
+ # Note: If using Drizzle/Prisma, run their studios instead:
63
+ # npx drizzle-kit studio (port 4983)
64
+ # npx prisma studio (port 5555)
62
65
  dbadmin:
63
- image: adminer:latest
66
+ image: sosedoff/pgweb:latest
64
67
  container_name: tlc-${COMPOSE_PROJECT_NAME:-dev}-dbadmin
65
68
  ports:
66
- - "${DBADMIN_PORT:-8080}:8080"
69
+ - "${DBADMIN_PORT:-8081}:8081"
67
70
  environment:
68
- - ADMINER_DEFAULT_SERVER=db
69
- - ADMINER_DESIGN=nette
71
+ - PGWEB_DATABASE_URL=postgres://postgres:postgres@db:5432/app?sslmode=disable
70
72
  depends_on:
71
- - db
73
+ db:
74
+ condition: service_healthy
72
75
  restart: on-failure
73
76
 
74
77
  # MinIO S3-Compatible Storage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "1.2.24",
3
+ "version": "1.2.26",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
6
  "tlc": "./bin/tlc.js",