tlc-claude-code 1.2.28 → 1.3.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.
- package/README.md +9 -4
- package/dashboard/dist/components/UsagePane.d.ts +13 -0
- package/dashboard/dist/components/UsagePane.js +51 -0
- package/dashboard/dist/components/UsagePane.test.d.ts +1 -0
- package/dashboard/dist/components/UsagePane.test.js +142 -0
- package/dashboard/dist/components/WorkspaceDocsPane.d.ts +19 -0
- package/dashboard/dist/components/WorkspaceDocsPane.js +146 -0
- package/dashboard/dist/components/WorkspaceDocsPane.test.d.ts +1 -0
- package/dashboard/dist/components/WorkspaceDocsPane.test.js +242 -0
- package/dashboard/dist/components/WorkspacePane.d.ts +18 -0
- package/dashboard/dist/components/WorkspacePane.js +17 -0
- package/dashboard/dist/components/WorkspacePane.test.d.ts +1 -0
- package/dashboard/dist/components/WorkspacePane.test.js +84 -0
- package/package.json +15 -4
- package/scripts/capture-screenshots.js +170 -0
- package/scripts/docs-update.js +253 -0
- package/scripts/generate-screenshots.js +321 -0
- package/scripts/project-docs.js +377 -0
- package/scripts/vps-setup.sh +477 -0
- package/server/lib/architecture-command.js +450 -0
- package/server/lib/architecture-command.test.js +754 -0
- package/server/lib/ast-analyzer.js +324 -0
- package/server/lib/ast-analyzer.test.js +437 -0
- package/server/lib/auth-system.test.js +4 -1
- package/server/lib/boundary-detector.js +427 -0
- package/server/lib/boundary-detector.test.js +320 -0
- package/server/lib/budget-alerts.js +138 -0
- package/server/lib/budget-alerts.test.js +235 -0
- package/server/lib/candidates-tracker.js +210 -0
- package/server/lib/candidates-tracker.test.js +300 -0
- package/server/lib/checkpoint-manager.js +251 -0
- package/server/lib/checkpoint-manager.test.js +474 -0
- package/server/lib/circular-detector.js +337 -0
- package/server/lib/circular-detector.test.js +353 -0
- package/server/lib/cohesion-analyzer.js +310 -0
- package/server/lib/cohesion-analyzer.test.js +447 -0
- package/server/lib/contract-testing.js +625 -0
- package/server/lib/contract-testing.test.js +342 -0
- package/server/lib/conversion-planner.js +469 -0
- package/server/lib/conversion-planner.test.js +361 -0
- package/server/lib/convert-command.js +351 -0
- package/server/lib/convert-command.test.js +608 -0
- package/server/lib/coupling-calculator.js +189 -0
- package/server/lib/coupling-calculator.test.js +509 -0
- package/server/lib/dependency-graph.js +367 -0
- package/server/lib/dependency-graph.test.js +516 -0
- package/server/lib/duplication-detector.js +349 -0
- package/server/lib/duplication-detector.test.js +401 -0
- package/server/lib/example-service.js +616 -0
- package/server/lib/example-service.test.js +397 -0
- package/server/lib/impact-scorer.js +184 -0
- package/server/lib/impact-scorer.test.js +211 -0
- package/server/lib/mermaid-generator.js +358 -0
- package/server/lib/mermaid-generator.test.js +301 -0
- package/server/lib/messaging-patterns.js +750 -0
- package/server/lib/messaging-patterns.test.js +213 -0
- package/server/lib/microservice-template.js +386 -0
- package/server/lib/microservice-template.test.js +325 -0
- package/server/lib/new-project-microservice.js +450 -0
- package/server/lib/new-project-microservice.test.js +600 -0
- package/server/lib/refactor-command.js +326 -0
- package/server/lib/refactor-command.test.js +528 -0
- package/server/lib/refactor-executor.js +254 -0
- package/server/lib/refactor-executor.test.js +305 -0
- package/server/lib/refactor-observer.js +292 -0
- package/server/lib/refactor-observer.test.js +422 -0
- package/server/lib/refactor-progress.js +193 -0
- package/server/lib/refactor-progress.test.js +251 -0
- package/server/lib/refactor-reporter.js +237 -0
- package/server/lib/refactor-reporter.test.js +247 -0
- package/server/lib/semantic-analyzer.js +198 -0
- package/server/lib/semantic-analyzer.test.js +474 -0
- package/server/lib/service-scaffold.js +486 -0
- package/server/lib/service-scaffold.test.js +373 -0
- package/server/lib/shared-kernel.js +578 -0
- package/server/lib/shared-kernel.test.js +255 -0
- package/server/lib/traefik-config.js +282 -0
- package/server/lib/traefik-config.test.js +312 -0
- package/server/lib/usage-command.js +218 -0
- package/server/lib/usage-command.test.js +391 -0
- package/server/lib/usage-formatter.js +192 -0
- package/server/lib/usage-formatter.test.js +267 -0
- package/server/lib/usage-history.js +122 -0
- package/server/lib/usage-history.test.js +206 -0
- package/server/package-lock.json +14 -0
- package/server/package.json +1 -0
- package/templates/docs-sync.yml +91 -0
package/README.md
CHANGED
|
@@ -58,10 +58,12 @@ No manual testing. No "does this work?" No vibes.
|
|
|
58
58
|
### Then Just Run
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
|
-
/tlc
|
|
61
|
+
/tlc:next
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
Shows what's next, asks "Proceed? [Y/n]", then executes. That's it.
|
|
65
|
+
|
|
66
|
+
Or use `/tlc` for the full dashboard.
|
|
65
67
|
|
|
66
68
|
---
|
|
67
69
|
|
|
@@ -70,6 +72,8 @@ TLC knows where you are and what's next.
|
|
|
70
72
|
### For Solo Developers
|
|
71
73
|
|
|
72
74
|
- **Test-first by default** — Claude writes tests before code
|
|
75
|
+
- **Auto-parallelization** — Up to 10 agents run independent tasks simultaneously
|
|
76
|
+
- **`/tlc:next`** — One command to progress. No decisions needed.
|
|
73
77
|
- **Smart dashboard** — See progress, run actions
|
|
74
78
|
- **Coverage gaps** — Find and fix untested code
|
|
75
79
|
- **Auto-fix** — Automatically repair failing tests
|
|
@@ -95,10 +99,11 @@ TLC knows where you are and what's next.
|
|
|
95
99
|
|
|
96
100
|
| Command | What It Does |
|
|
97
101
|
|---------|--------------|
|
|
98
|
-
| `/tlc` | **
|
|
102
|
+
| `/tlc:next` | **Just do it — shows next action, asks once, executes** |
|
|
103
|
+
| `/tlc` | Smart dashboard — full status view |
|
|
99
104
|
| `/tlc:new-project` | Start new project with roadmap |
|
|
100
105
|
| `/tlc:init` | Add TLC to existing codebase |
|
|
101
|
-
| `/tlc:build` | Write tests → implement
|
|
106
|
+
| `/tlc:build` | Write tests → implement (auto-parallelizes up to 10 agents) |
|
|
102
107
|
| `/tlc:coverage` | Find and fix untested code |
|
|
103
108
|
| `/tlc:quality` | Test quality scoring |
|
|
104
109
|
| `/tlc:autofix` | Auto-repair failing tests |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface ModelUsage {
|
|
2
|
+
daily: number;
|
|
3
|
+
monthly: number;
|
|
4
|
+
requests: number;
|
|
5
|
+
budgetDaily: number;
|
|
6
|
+
budgetMonthly: number;
|
|
7
|
+
}
|
|
8
|
+
interface UsagePaneProps {
|
|
9
|
+
data?: Record<string, ModelUsage>;
|
|
10
|
+
alerts?: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function UsagePane({ data, alerts }: UsagePaneProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
function formatCurrency(amount) {
|
|
4
|
+
return '$' + amount.toFixed(2);
|
|
5
|
+
}
|
|
6
|
+
function getUsageColor(used, budget) {
|
|
7
|
+
if (budget === 0)
|
|
8
|
+
return 'gray';
|
|
9
|
+
const percent = (used / budget) * 100;
|
|
10
|
+
if (percent >= 100)
|
|
11
|
+
return 'red';
|
|
12
|
+
if (percent >= 80)
|
|
13
|
+
return 'yellow';
|
|
14
|
+
return 'green';
|
|
15
|
+
}
|
|
16
|
+
function createBar(used, budget, width = 15) {
|
|
17
|
+
if (budget === 0)
|
|
18
|
+
return '░'.repeat(width);
|
|
19
|
+
const percent = Math.min(used / budget, 1.5); // Cap at 150% for display
|
|
20
|
+
const filled = Math.round(percent * width);
|
|
21
|
+
const bar = '█'.repeat(Math.min(filled, width)) + '░'.repeat(Math.max(0, width - filled));
|
|
22
|
+
return bar;
|
|
23
|
+
}
|
|
24
|
+
export function UsagePane({ data, alerts }) {
|
|
25
|
+
if (!data || Object.keys(data).length === 0) {
|
|
26
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Usage Dashboard" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "No usage data available." }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Run /tlc:usage to view." }) })] }));
|
|
27
|
+
}
|
|
28
|
+
const models = Object.keys(data);
|
|
29
|
+
// Calculate totals
|
|
30
|
+
let totalDaily = 0;
|
|
31
|
+
let totalMonthly = 0;
|
|
32
|
+
let totalRequests = 0;
|
|
33
|
+
let totalBudgetDaily = 0;
|
|
34
|
+
let totalBudgetMonthly = 0;
|
|
35
|
+
for (const model of models) {
|
|
36
|
+
const usage = data[model];
|
|
37
|
+
totalDaily += usage.daily;
|
|
38
|
+
totalMonthly += usage.monthly;
|
|
39
|
+
totalRequests += usage.requests;
|
|
40
|
+
totalBudgetDaily += usage.budgetDaily;
|
|
41
|
+
totalBudgetMonthly += usage.budgetMonthly;
|
|
42
|
+
}
|
|
43
|
+
const totalDailyPercent = totalBudgetDaily > 0 ? Math.round((totalDaily / totalBudgetDaily) * 100) : 0;
|
|
44
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Usage Dashboard" }), alerts && alerts.length > 0 && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: alerts.map((alert, idx) => (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ["\u26A0 ", alert] }) }, idx))) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: models.map((model) => {
|
|
45
|
+
const usage = data[model];
|
|
46
|
+
const dailyPercent = usage.budgetDaily > 0 ? Math.round((usage.daily / usage.budgetDaily) * 100) : 0;
|
|
47
|
+
const isOver = usage.daily > usage.budgetDaily;
|
|
48
|
+
const color = getUsageColor(usage.daily, usage.budgetDaily);
|
|
49
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: model }), isOver && _jsx(Text, { color: "red", children: " OVER" })] }), _jsxs(Box, { children: [_jsxs(Text, { color: color, children: ["[", createBar(usage.daily, usage.budgetDaily), "]"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: color, children: formatCurrency(usage.daily) }), _jsxs(Text, { dimColor: true, children: [" / ", formatCurrency(usage.budgetDaily)] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: color, children: [dailyPercent, "%"] })] }), _jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [" Requests: ", usage.requests] }) })] }, model));
|
|
50
|
+
}) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Totals" }), _jsxs(Box, { children: [_jsx(Text, { children: "Daily: " }), _jsx(Text, { color: getUsageColor(totalDaily, totalBudgetDaily), children: formatCurrency(totalDaily) }), _jsxs(Text, { dimColor: true, children: [" / ", formatCurrency(totalBudgetDaily)] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: getUsageColor(totalDaily, totalBudgetDaily), children: [totalDailyPercent, "%"] })] }), _jsxs(Box, { children: [_jsx(Text, { children: "Monthly: " }), _jsx(Text, { color: getUsageColor(totalMonthly, totalBudgetMonthly), children: formatCurrency(totalMonthly) }), _jsxs(Text, { dimColor: true, children: [" / ", formatCurrency(totalBudgetMonthly)] })] }), _jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["Requests: ", totalRequests] }) })] })] }));
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { UsagePane } from './UsagePane.js';
|
|
5
|
+
describe('UsagePane', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
it('renders without error', () => {
|
|
13
|
+
const { lastFrame } = render(_jsx(UsagePane, {}));
|
|
14
|
+
expect(lastFrame()).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
it('shows placeholder when no usage data', () => {
|
|
17
|
+
const { lastFrame } = render(_jsx(UsagePane, {}));
|
|
18
|
+
const output = lastFrame();
|
|
19
|
+
expect(output).toContain('Usage');
|
|
20
|
+
});
|
|
21
|
+
it('renders usage bars for each model', () => {
|
|
22
|
+
const usageData = {
|
|
23
|
+
openai: { daily: 5, monthly: 50, requests: 100, budgetDaily: 10, budgetMonthly: 100 },
|
|
24
|
+
deepseek: { daily: 2, monthly: 20, requests: 50, budgetDaily: 5, budgetMonthly: 50 },
|
|
25
|
+
};
|
|
26
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
27
|
+
const output = lastFrame();
|
|
28
|
+
expect(output).toContain('openai');
|
|
29
|
+
expect(output).toContain('deepseek');
|
|
30
|
+
});
|
|
31
|
+
it('shows model names', () => {
|
|
32
|
+
const usageData = {
|
|
33
|
+
'gpt-4': { daily: 3, monthly: 30, requests: 60, budgetDaily: 10, budgetMonthly: 100 },
|
|
34
|
+
};
|
|
35
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
36
|
+
const output = lastFrame();
|
|
37
|
+
expect(output).toContain('gpt-4');
|
|
38
|
+
});
|
|
39
|
+
it('shows dollar amounts', () => {
|
|
40
|
+
const usageData = {
|
|
41
|
+
openai: { daily: 5.50, monthly: 55.25, requests: 100, budgetDaily: 10, budgetMonthly: 100 },
|
|
42
|
+
};
|
|
43
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
44
|
+
const output = lastFrame();
|
|
45
|
+
expect(output).toContain('$5.50');
|
|
46
|
+
});
|
|
47
|
+
it('shows budget limit', () => {
|
|
48
|
+
const usageData = {
|
|
49
|
+
openai: { daily: 5, monthly: 50, requests: 100, budgetDaily: 10, budgetMonthly: 100 },
|
|
50
|
+
};
|
|
51
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
52
|
+
const output = lastFrame();
|
|
53
|
+
expect(output).toContain('$10');
|
|
54
|
+
});
|
|
55
|
+
it('highlights over-budget with warning color', () => {
|
|
56
|
+
const usageData = {
|
|
57
|
+
openai: { daily: 12, monthly: 120, requests: 150, budgetDaily: 10, budgetMonthly: 100 },
|
|
58
|
+
};
|
|
59
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
60
|
+
const output = lastFrame();
|
|
61
|
+
// Should show the over-budget amount
|
|
62
|
+
expect(output).toContain('$12');
|
|
63
|
+
// Should have some indicator of over-budget
|
|
64
|
+
expect(output).toContain('OVER');
|
|
65
|
+
});
|
|
66
|
+
it('shows alert messages when provided', () => {
|
|
67
|
+
const usageData = {
|
|
68
|
+
openai: { daily: 8, monthly: 80, requests: 100, budgetDaily: 10, budgetMonthly: 100 },
|
|
69
|
+
};
|
|
70
|
+
const alerts = ['Budget Alert: openai at 80% of daily budget'];
|
|
71
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData, alerts: alerts }));
|
|
72
|
+
const output = lastFrame();
|
|
73
|
+
expect(output).toContain('80%');
|
|
74
|
+
});
|
|
75
|
+
it('shows percentage of budget used', () => {
|
|
76
|
+
const usageData = {
|
|
77
|
+
openai: { daily: 5, monthly: 50, requests: 100, budgetDaily: 10, budgetMonthly: 100 },
|
|
78
|
+
};
|
|
79
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
80
|
+
const output = lastFrame();
|
|
81
|
+
expect(output).toContain('50%');
|
|
82
|
+
});
|
|
83
|
+
it('updates on data change', () => {
|
|
84
|
+
const usageData1 = {
|
|
85
|
+
openai: { daily: 5, monthly: 50, requests: 100, budgetDaily: 10, budgetMonthly: 100 },
|
|
86
|
+
};
|
|
87
|
+
const { lastFrame, rerender } = render(_jsx(UsagePane, { data: usageData1 }));
|
|
88
|
+
let output = lastFrame();
|
|
89
|
+
expect(output).toContain('$5');
|
|
90
|
+
const usageData2 = {
|
|
91
|
+
openai: { daily: 7, monthly: 70, requests: 140, budgetDaily: 10, budgetMonthly: 100 },
|
|
92
|
+
};
|
|
93
|
+
rerender(_jsx(UsagePane, { data: usageData2 }));
|
|
94
|
+
output = lastFrame();
|
|
95
|
+
expect(output).toContain('$7');
|
|
96
|
+
});
|
|
97
|
+
it('shows totals across all models', () => {
|
|
98
|
+
const usageData = {
|
|
99
|
+
openai: { daily: 5, monthly: 50, requests: 100, budgetDaily: 10, budgetMonthly: 100 },
|
|
100
|
+
deepseek: { daily: 3, monthly: 30, requests: 60, budgetDaily: 5, budgetMonthly: 50 },
|
|
101
|
+
};
|
|
102
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
103
|
+
const output = lastFrame();
|
|
104
|
+
// Total daily: $8, Total budget: $15
|
|
105
|
+
expect(output).toContain('$8');
|
|
106
|
+
});
|
|
107
|
+
it('shows visual progress bar for usage', () => {
|
|
108
|
+
const usageData = {
|
|
109
|
+
openai: { daily: 5, monthly: 50, requests: 100, budgetDaily: 10, budgetMonthly: 100 },
|
|
110
|
+
};
|
|
111
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
112
|
+
const output = lastFrame();
|
|
113
|
+
// Should contain some visual bar representation
|
|
114
|
+
expect(output).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
it('handles zero usage gracefully', () => {
|
|
117
|
+
const usageData = {
|
|
118
|
+
openai: { daily: 0, monthly: 0, requests: 0, budgetDaily: 10, budgetMonthly: 100 },
|
|
119
|
+
};
|
|
120
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
121
|
+
const output = lastFrame();
|
|
122
|
+
expect(output).toContain('$0');
|
|
123
|
+
expect(output).toContain('0%');
|
|
124
|
+
});
|
|
125
|
+
it('handles missing budget values', () => {
|
|
126
|
+
const usageData = {
|
|
127
|
+
openai: { daily: 5, monthly: 50, requests: 100, budgetDaily: 0, budgetMonthly: 0 },
|
|
128
|
+
};
|
|
129
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
130
|
+
const output = lastFrame();
|
|
131
|
+
// Should still render without errors
|
|
132
|
+
expect(output).toBeDefined();
|
|
133
|
+
});
|
|
134
|
+
it('shows request count', () => {
|
|
135
|
+
const usageData = {
|
|
136
|
+
openai: { daily: 5, monthly: 50, requests: 150, budgetDaily: 10, budgetMonthly: 100 },
|
|
137
|
+
};
|
|
138
|
+
const { lastFrame } = render(_jsx(UsagePane, { data: usageData }));
|
|
139
|
+
const output = lastFrame();
|
|
140
|
+
expect(output).toContain('150');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface WorkspaceDoc {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
path: string;
|
|
5
|
+
type: 'markdown' | 'mermaid';
|
|
6
|
+
content?: string;
|
|
7
|
+
linkedDocs?: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface WorkspaceDocsPaneProps {
|
|
10
|
+
docs?: WorkspaceDoc[];
|
|
11
|
+
selectedDocId?: string;
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
isActive: boolean;
|
|
14
|
+
onRefresh?: () => void;
|
|
15
|
+
onRegenerate?: () => void;
|
|
16
|
+
onSelectDoc?: (docId: string) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare function WorkspaceDocsPane({ docs, selectedDocId, loading, isActive, onRefresh, onRegenerate, onSelectDoc, }: WorkspaceDocsPaneProps): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
export default WorkspaceDocsPane;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* Parse markdown content and render as terminal-friendly text
|
|
6
|
+
*/
|
|
7
|
+
function renderMarkdownLine(line) {
|
|
8
|
+
// Heading detection
|
|
9
|
+
if (line.startsWith('# ')) {
|
|
10
|
+
return { text: line.slice(2), bold: true, dimColor: false, color: 'cyan' };
|
|
11
|
+
}
|
|
12
|
+
if (line.startsWith('## ')) {
|
|
13
|
+
return { text: line.slice(3), bold: true, dimColor: false, color: 'cyan' };
|
|
14
|
+
}
|
|
15
|
+
if (line.startsWith('### ')) {
|
|
16
|
+
return { text: line.slice(4), bold: true, dimColor: false };
|
|
17
|
+
}
|
|
18
|
+
return { text: line, bold: false, dimColor: false };
|
|
19
|
+
}
|
|
20
|
+
function parseMarkdown(content) {
|
|
21
|
+
const blocks = [];
|
|
22
|
+
const lines = content.split('\n');
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (i < lines.length) {
|
|
25
|
+
const line = lines[i];
|
|
26
|
+
// Check for code blocks (including mermaid)
|
|
27
|
+
if (line.startsWith('```')) {
|
|
28
|
+
const language = line.slice(3).trim();
|
|
29
|
+
const codeLines = [];
|
|
30
|
+
i++;
|
|
31
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
32
|
+
codeLines.push(lines[i]);
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
if (language === 'mermaid') {
|
|
36
|
+
blocks.push({
|
|
37
|
+
type: 'mermaid',
|
|
38
|
+
content: codeLines.join('\n'),
|
|
39
|
+
language: 'mermaid'
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
blocks.push({
|
|
44
|
+
type: 'code',
|
|
45
|
+
content: codeLines.join('\n'),
|
|
46
|
+
language: language || 'text'
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Check for headings
|
|
53
|
+
if (line.startsWith('#')) {
|
|
54
|
+
blocks.push({
|
|
55
|
+
type: 'heading',
|
|
56
|
+
content: line.replace(/^#+\s*/, '')
|
|
57
|
+
});
|
|
58
|
+
i++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Regular text
|
|
62
|
+
if (line.trim()) {
|
|
63
|
+
blocks.push({
|
|
64
|
+
type: 'text',
|
|
65
|
+
content: line
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
return blocks;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Render a content block as Ink elements
|
|
74
|
+
*/
|
|
75
|
+
function ContentBlockView({ block }) {
|
|
76
|
+
switch (block.type) {
|
|
77
|
+
case 'heading':
|
|
78
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: "cyan", children: block.content }) }));
|
|
79
|
+
case 'code':
|
|
80
|
+
return (_jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { dimColor: true, children: block.language }), _jsx(Text, { children: block.content })] }));
|
|
81
|
+
case 'mermaid':
|
|
82
|
+
return (_jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "double", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Mermaid Diagram" }), _jsx(Text, { dimColor: true, children: block.content })] }));
|
|
83
|
+
case 'text':
|
|
84
|
+
default:
|
|
85
|
+
return _jsx(Text, { children: block.content });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Doc list item component
|
|
90
|
+
*/
|
|
91
|
+
function DocListItem({ doc, isSelected, }) {
|
|
92
|
+
const linkCount = doc.linkedDocs?.length || 0;
|
|
93
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, underline: isSelected, children: [isSelected ? '> ' : ' ', doc.name] }), linkCount > 0 && (_jsxs(Text, { dimColor: true, children: [" (", linkCount, " links)"] }))] }));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Document content viewer
|
|
97
|
+
*/
|
|
98
|
+
function DocContentView({ doc }) {
|
|
99
|
+
if (!doc.content) {
|
|
100
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No content available" }) }));
|
|
101
|
+
}
|
|
102
|
+
const blocks = parseMarkdown(doc.content);
|
|
103
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: doc.name }), blocks.map((block, idx) => (_jsx(ContentBlockView, { block: block }, idx)))] }));
|
|
104
|
+
}
|
|
105
|
+
export function WorkspaceDocsPane({ docs = [], selectedDocId, loading = false, isActive, onRefresh, onRegenerate, onSelectDoc, }) {
|
|
106
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
107
|
+
// Get actual selected doc (from prop or from index)
|
|
108
|
+
const actualSelectedDocId = selectedDocId || docs[selectedIndex]?.id;
|
|
109
|
+
const selectedDoc = docs.find(d => d.id === actualSelectedDocId);
|
|
110
|
+
useInput((input, key) => {
|
|
111
|
+
if (!isActive)
|
|
112
|
+
return;
|
|
113
|
+
// Navigation
|
|
114
|
+
if (key.upArrow) {
|
|
115
|
+
const newIndex = Math.max(0, selectedIndex - 1);
|
|
116
|
+
setSelectedIndex(newIndex);
|
|
117
|
+
if (docs[newIndex] && onSelectDoc) {
|
|
118
|
+
onSelectDoc(docs[newIndex].id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (key.downArrow) {
|
|
122
|
+
const newIndex = Math.min(docs.length - 1, selectedIndex + 1);
|
|
123
|
+
setSelectedIndex(newIndex);
|
|
124
|
+
if (docs[newIndex] && onSelectDoc) {
|
|
125
|
+
onSelectDoc(docs[newIndex].id);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Actions
|
|
129
|
+
if (input === 'r') {
|
|
130
|
+
onRefresh?.();
|
|
131
|
+
}
|
|
132
|
+
if (input === 'g') {
|
|
133
|
+
onRegenerate?.();
|
|
134
|
+
}
|
|
135
|
+
}, { isActive });
|
|
136
|
+
// Loading state
|
|
137
|
+
if (loading) {
|
|
138
|
+
return (_jsx(Box, { padding: 1, flexDirection: "column", children: _jsx(Text, { color: "yellow", children: "Loading docs..." }) }));
|
|
139
|
+
}
|
|
140
|
+
// Empty state
|
|
141
|
+
if (docs.length === 0) {
|
|
142
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Workspace Docs" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "No docs generated yet." }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Run /tlc:workspace-docs to generate documentation" }) })] }));
|
|
143
|
+
}
|
|
144
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Workspace Docs" }), _jsxs(Text, { dimColor: true, children: [" (", docs.length, ")"] })] }), _jsxs(Box, { flexDirection: "column", children: [docs.slice(0, 10).map((doc, idx) => (_jsx(DocListItem, { doc: doc, isSelected: doc.id === actualSelectedDocId }, doc.id))), docs.length > 10 && (_jsxs(Text, { dimColor: true, children: ["... and ", docs.length - 10, " more"] }))] }), selectedDoc && _jsx(DocContentView, { doc: selectedDoc }), isActive && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[arrows] Navigate [r] Refresh [g] Regenerate" }) }))] }));
|
|
145
|
+
}
|
|
146
|
+
export default WorkspaceDocsPane;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { WorkspaceDocsPane } from './WorkspaceDocsPane.js';
|
|
5
|
+
describe('WorkspaceDocsPane', () => {
|
|
6
|
+
describe('doc list', () => {
|
|
7
|
+
it('renders doc list', () => {
|
|
8
|
+
const docs = [
|
|
9
|
+
{ id: 'readme', name: 'README.md', path: '/docs/README.md', type: 'markdown' },
|
|
10
|
+
{ id: 'api', name: 'API.md', path: '/docs/API.md', type: 'markdown' },
|
|
11
|
+
];
|
|
12
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: false }));
|
|
13
|
+
const output = lastFrame();
|
|
14
|
+
expect(output).toContain('README.md');
|
|
15
|
+
expect(output).toContain('API.md');
|
|
16
|
+
});
|
|
17
|
+
it('shows doc count in header', () => {
|
|
18
|
+
const docs = [
|
|
19
|
+
{ id: 'doc1', name: 'Doc1.md', path: '/docs/Doc1.md', type: 'markdown' },
|
|
20
|
+
{ id: 'doc2', name: 'Doc2.md', path: '/docs/Doc2.md', type: 'markdown' },
|
|
21
|
+
{ id: 'doc3', name: 'Doc3.md', path: '/docs/Doc3.md', type: 'markdown' },
|
|
22
|
+
];
|
|
23
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: false }));
|
|
24
|
+
const output = lastFrame();
|
|
25
|
+
expect(output).toContain('3');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('markdown content', () => {
|
|
29
|
+
it('shows markdown content for selected doc', () => {
|
|
30
|
+
const docs = [
|
|
31
|
+
{
|
|
32
|
+
id: 'readme',
|
|
33
|
+
name: 'README.md',
|
|
34
|
+
path: '/docs/README.md',
|
|
35
|
+
type: 'markdown',
|
|
36
|
+
content: '# Project Title\n\nThis is the readme content.'
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, selectedDocId: "readme", isActive: false }));
|
|
40
|
+
const output = lastFrame();
|
|
41
|
+
expect(output).toContain('Project Title');
|
|
42
|
+
expect(output).toContain('readme content');
|
|
43
|
+
});
|
|
44
|
+
it('renders headings distinctly', () => {
|
|
45
|
+
const docs = [
|
|
46
|
+
{
|
|
47
|
+
id: 'doc1',
|
|
48
|
+
name: 'Doc.md',
|
|
49
|
+
path: '/docs/Doc.md',
|
|
50
|
+
type: 'markdown',
|
|
51
|
+
content: '## Section Heading\n\nParagraph text.'
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, selectedDocId: "doc1", isActive: false }));
|
|
55
|
+
const output = lastFrame();
|
|
56
|
+
expect(output).toContain('Section Heading');
|
|
57
|
+
});
|
|
58
|
+
it('renders code blocks', () => {
|
|
59
|
+
const docs = [
|
|
60
|
+
{
|
|
61
|
+
id: 'code-doc',
|
|
62
|
+
name: 'Code.md',
|
|
63
|
+
path: '/docs/Code.md',
|
|
64
|
+
type: 'markdown',
|
|
65
|
+
content: '```typescript\nconst x = 1;\n```'
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, selectedDocId: "code-doc", isActive: false }));
|
|
69
|
+
const output = lastFrame();
|
|
70
|
+
expect(output).toContain('const x = 1');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('mermaid diagrams', () => {
|
|
74
|
+
it('renders Mermaid diagrams', () => {
|
|
75
|
+
const docs = [
|
|
76
|
+
{
|
|
77
|
+
id: 'arch',
|
|
78
|
+
name: 'Architecture.md',
|
|
79
|
+
path: '/docs/Architecture.md',
|
|
80
|
+
type: 'markdown',
|
|
81
|
+
content: '```mermaid\ngraph TD\n A[Start] --> B[End]\n```'
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, selectedDocId: "arch", isActive: false }));
|
|
85
|
+
const output = lastFrame();
|
|
86
|
+
// In terminal, mermaid is rendered as text representation
|
|
87
|
+
expect(output).toContain('Mermaid');
|
|
88
|
+
});
|
|
89
|
+
it('shows diagram type indicator', () => {
|
|
90
|
+
const docs = [
|
|
91
|
+
{
|
|
92
|
+
id: 'flow',
|
|
93
|
+
name: 'Flow.md',
|
|
94
|
+
path: '/docs/Flow.md',
|
|
95
|
+
type: 'markdown',
|
|
96
|
+
content: '```mermaid\nsequenceDiagram\n A->>B: Message\n```'
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, selectedDocId: "flow", isActive: false }));
|
|
100
|
+
const output = lastFrame();
|
|
101
|
+
expect(output).toContain('sequenceDiagram');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('doc links', () => {
|
|
105
|
+
it('shows links between related docs', () => {
|
|
106
|
+
const docs = [
|
|
107
|
+
{
|
|
108
|
+
id: 'main',
|
|
109
|
+
name: 'Main.md',
|
|
110
|
+
path: '/docs/Main.md',
|
|
111
|
+
type: 'markdown',
|
|
112
|
+
content: 'See [API Documentation](./API.md)',
|
|
113
|
+
linkedDocs: ['api']
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'api',
|
|
117
|
+
name: 'API.md',
|
|
118
|
+
path: '/docs/API.md',
|
|
119
|
+
type: 'markdown'
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, selectedDocId: "main", isActive: false }));
|
|
123
|
+
const output = lastFrame();
|
|
124
|
+
expect(output).toContain('API');
|
|
125
|
+
});
|
|
126
|
+
it('indicates linked docs in list view', () => {
|
|
127
|
+
const docs = [
|
|
128
|
+
{
|
|
129
|
+
id: 'main',
|
|
130
|
+
name: 'Main.md',
|
|
131
|
+
path: '/docs/Main.md',
|
|
132
|
+
type: 'markdown',
|
|
133
|
+
linkedDocs: ['api', 'readme']
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: false }));
|
|
137
|
+
const output = lastFrame();
|
|
138
|
+
// Should show link count
|
|
139
|
+
expect(output).toContain('2');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe('loading state', () => {
|
|
143
|
+
it('shows loading state', () => {
|
|
144
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { loading: true, isActive: false }));
|
|
145
|
+
const output = lastFrame();
|
|
146
|
+
expect(output).toContain('Loading');
|
|
147
|
+
});
|
|
148
|
+
it('shows loading indicator while refreshing', () => {
|
|
149
|
+
const docs = [
|
|
150
|
+
{ id: 'doc1', name: 'Doc.md', path: '/docs/Doc.md', type: 'markdown' },
|
|
151
|
+
];
|
|
152
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, loading: true, isActive: false }));
|
|
153
|
+
const output = lastFrame();
|
|
154
|
+
expect(output).toContain('Loading');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
describe('empty state', () => {
|
|
158
|
+
it('shows empty state when no docs', () => {
|
|
159
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: [], isActive: false }));
|
|
160
|
+
const output = lastFrame();
|
|
161
|
+
expect(output).toContain('No docs');
|
|
162
|
+
});
|
|
163
|
+
it('shows hint to generate docs', () => {
|
|
164
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: [], isActive: false }));
|
|
165
|
+
const output = lastFrame();
|
|
166
|
+
expect(output).toContain('/tlc');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('refresh/regenerate', () => {
|
|
170
|
+
it('shows refresh button when active', () => {
|
|
171
|
+
const docs = [
|
|
172
|
+
{ id: 'doc1', name: 'Doc.md', path: '/docs/Doc.md', type: 'markdown' },
|
|
173
|
+
];
|
|
174
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: true }));
|
|
175
|
+
const output = lastFrame();
|
|
176
|
+
expect(output).toContain('Refresh');
|
|
177
|
+
});
|
|
178
|
+
it('accepts onRefresh callback prop', () => {
|
|
179
|
+
const onRefresh = vi.fn();
|
|
180
|
+
const docs = [
|
|
181
|
+
{ id: 'doc1', name: 'Doc.md', path: '/docs/Doc.md', type: 'markdown' },
|
|
182
|
+
];
|
|
183
|
+
// Verify component renders with callback without errors
|
|
184
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: true, onRefresh: onRefresh }));
|
|
185
|
+
const output = lastFrame();
|
|
186
|
+
// Controls should be shown indicating 'r' key works
|
|
187
|
+
expect(output).toContain('r');
|
|
188
|
+
expect(output).toContain('Refresh');
|
|
189
|
+
});
|
|
190
|
+
it('shows regenerate option', () => {
|
|
191
|
+
const docs = [
|
|
192
|
+
{ id: 'doc1', name: 'Doc.md', path: '/docs/Doc.md', type: 'markdown' },
|
|
193
|
+
];
|
|
194
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: true }));
|
|
195
|
+
const output = lastFrame();
|
|
196
|
+
expect(output).toContain('Regenerate');
|
|
197
|
+
});
|
|
198
|
+
it('accepts onRegenerate callback prop', () => {
|
|
199
|
+
const onRegenerate = vi.fn();
|
|
200
|
+
const docs = [
|
|
201
|
+
{ id: 'doc1', name: 'Doc.md', path: '/docs/Doc.md', type: 'markdown' },
|
|
202
|
+
];
|
|
203
|
+
// Verify component renders with callback without errors
|
|
204
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: true, onRegenerate: onRegenerate }));
|
|
205
|
+
const output = lastFrame();
|
|
206
|
+
// Controls should be shown indicating 'g' key works
|
|
207
|
+
expect(output).toContain('g');
|
|
208
|
+
expect(output).toContain('Regenerate');
|
|
209
|
+
});
|
|
210
|
+
it('hides controls when not active', () => {
|
|
211
|
+
const docs = [
|
|
212
|
+
{ id: 'doc1', name: 'Doc.md', path: '/docs/Doc.md', type: 'markdown' },
|
|
213
|
+
];
|
|
214
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: false }));
|
|
215
|
+
const output = lastFrame();
|
|
216
|
+
expect(output).not.toContain('Refresh');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
describe('navigation', () => {
|
|
220
|
+
it('accepts onSelectDoc callback prop', () => {
|
|
221
|
+
const onSelectDoc = vi.fn();
|
|
222
|
+
const docs = [
|
|
223
|
+
{ id: 'doc1', name: 'First.md', path: '/docs/First.md', type: 'markdown' },
|
|
224
|
+
{ id: 'doc2', name: 'Second.md', path: '/docs/Second.md', type: 'markdown' },
|
|
225
|
+
];
|
|
226
|
+
// Verify component renders with callback without errors
|
|
227
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: true, onSelectDoc: onSelectDoc }));
|
|
228
|
+
const output = lastFrame();
|
|
229
|
+
// Controls should be shown indicating arrow keys work
|
|
230
|
+
expect(output).toContain('arrows');
|
|
231
|
+
expect(output).toContain('Navigate');
|
|
232
|
+
});
|
|
233
|
+
it('shows navigation hints when active', () => {
|
|
234
|
+
const docs = [
|
|
235
|
+
{ id: 'doc1', name: 'Doc.md', path: '/docs/Doc.md', type: 'markdown' },
|
|
236
|
+
];
|
|
237
|
+
const { lastFrame } = render(_jsx(WorkspaceDocsPane, { docs: docs, isActive: true }));
|
|
238
|
+
const output = lastFrame();
|
|
239
|
+
expect(output).toContain('Navigate');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|