tlc-claude-code 0.6.2 → 0.6.3
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/dashboard/dist/App.d.ts +5 -0
- package/dashboard/dist/App.js +49 -0
- package/dashboard/dist/App.test.d.ts +1 -0
- package/dashboard/dist/App.test.js +137 -0
- package/dashboard/dist/components/AgentsPane.d.ts +18 -0
- package/dashboard/dist/components/AgentsPane.js +77 -0
- package/dashboard/dist/components/AgentsPane.test.d.ts +1 -0
- package/dashboard/dist/components/AgentsPane.test.js +52 -0
- package/dashboard/dist/components/ChatPane.d.ts +6 -0
- package/dashboard/dist/components/ChatPane.js +34 -0
- package/dashboard/dist/components/ChatPane.test.d.ts +1 -0
- package/dashboard/dist/components/ChatPane.test.js +36 -0
- package/dashboard/dist/components/GitHubPane.d.ts +16 -0
- package/dashboard/dist/components/GitHubPane.js +121 -0
- package/dashboard/dist/components/GitHubPane.test.d.ts +1 -0
- package/dashboard/dist/components/GitHubPane.test.js +79 -0
- package/dashboard/dist/components/PhasesPane.d.ts +8 -0
- package/dashboard/dist/components/PhasesPane.js +65 -0
- package/dashboard/dist/components/PhasesPane.test.d.ts +1 -0
- package/dashboard/dist/components/PhasesPane.test.js +119 -0
- package/dashboard/dist/components/PlanSync.d.ts +13 -0
- package/dashboard/dist/components/PlanSync.js +89 -0
- package/dashboard/dist/components/PlanSync.test.d.ts +1 -0
- package/dashboard/dist/components/PlanSync.test.js +117 -0
- package/dashboard/dist/components/PreviewPane.d.ts +6 -0
- package/dashboard/dist/components/PreviewPane.js +43 -0
- package/dashboard/dist/components/PreviewPane.test.d.ts +1 -0
- package/dashboard/dist/components/PreviewPane.test.js +36 -0
- package/dashboard/dist/components/StatusPane.d.ts +1 -0
- package/dashboard/dist/components/StatusPane.js +32 -0
- package/dashboard/dist/components/StatusPane.test.d.ts +1 -0
- package/dashboard/dist/components/StatusPane.test.js +51 -0
- package/dashboard/dist/index.d.ts +2 -0
- package/dashboard/dist/index.js +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import { ChatPane } from './components/ChatPane.js';
|
|
5
|
+
import { StatusPane } from './components/StatusPane.js';
|
|
6
|
+
import { PreviewPane } from './components/PreviewPane.js';
|
|
7
|
+
import { AgentsPane } from './components/AgentsPane.js';
|
|
8
|
+
import { GitHubPane } from './components/GitHubPane.js';
|
|
9
|
+
import { markIssueComplete, markIssueInProgress } from './components/PlanSync.js';
|
|
10
|
+
export function App({ isTTY = true }) {
|
|
11
|
+
const { exit } = useApp();
|
|
12
|
+
const [activePane, setActivePane] = useState('chat');
|
|
13
|
+
const [pendingTasks, setPendingTasks] = useState(new Map());
|
|
14
|
+
useInput((input, key) => {
|
|
15
|
+
if (input === 'q' && key.ctrl) {
|
|
16
|
+
exit();
|
|
17
|
+
}
|
|
18
|
+
if (key.tab) {
|
|
19
|
+
setActivePane(prev => {
|
|
20
|
+
const panes = ['chat', 'github', 'agents', 'preview'];
|
|
21
|
+
const idx = panes.indexOf(prev);
|
|
22
|
+
return panes[(idx + 1) % panes.length];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// Number keys to quick-switch panes
|
|
26
|
+
if (input === '1')
|
|
27
|
+
setActivePane('chat');
|
|
28
|
+
if (input === '2')
|
|
29
|
+
setActivePane('github');
|
|
30
|
+
if (input === '3')
|
|
31
|
+
setActivePane('agents');
|
|
32
|
+
if (input === '4')
|
|
33
|
+
setActivePane('preview');
|
|
34
|
+
}, { isActive: isTTY });
|
|
35
|
+
const handleAssignToAgent = useCallback(async (issue) => {
|
|
36
|
+
await markIssueInProgress(issue.number);
|
|
37
|
+
setPendingTasks(prev => new Map(prev).set(issue.number, issue.title));
|
|
38
|
+
// Agent assignment happens in AgentsPane
|
|
39
|
+
}, []);
|
|
40
|
+
const handleTaskComplete = useCallback(async (issueNumber) => {
|
|
41
|
+
await markIssueComplete(issueNumber);
|
|
42
|
+
setPendingTasks(prev => {
|
|
43
|
+
const next = new Map(prev);
|
|
44
|
+
next.delete(issueNumber);
|
|
45
|
+
return next;
|
|
46
|
+
});
|
|
47
|
+
}, []);
|
|
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" }) })] }));
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
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 { App } from './App.js';
|
|
5
|
+
// Mock the PlanSync module
|
|
6
|
+
vi.mock('./components/PlanSync.js', () => ({
|
|
7
|
+
markIssueComplete: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
markIssueInProgress: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
}));
|
|
10
|
+
// Mock child_process for StatusPane
|
|
11
|
+
vi.mock('child_process', () => ({
|
|
12
|
+
exec: vi.fn((cmd, opts, cb) => {
|
|
13
|
+
if (typeof opts === 'function') {
|
|
14
|
+
cb = opts;
|
|
15
|
+
}
|
|
16
|
+
const error = new Error('command not found');
|
|
17
|
+
if (cb)
|
|
18
|
+
cb(error, '', '');
|
|
19
|
+
return { stdout: null, stderr: null };
|
|
20
|
+
}),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('util', async () => {
|
|
23
|
+
const actual = await vi.importActual('util');
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
promisify: (fn) => async (...args) => {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
fn(...args, (err, stdout, stderr) => {
|
|
29
|
+
if (err)
|
|
30
|
+
reject(err);
|
|
31
|
+
else
|
|
32
|
+
resolve({ stdout, stderr });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
describe('App', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
describe('initial render', () => {
|
|
43
|
+
it('renders without error', () => {
|
|
44
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
45
|
+
expect(lastFrame()).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
it('shows TDD Dashboard header', () => {
|
|
48
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
49
|
+
const output = lastFrame();
|
|
50
|
+
expect(output).toContain('TDD Dashboard');
|
|
51
|
+
});
|
|
52
|
+
it('shows all pane labels in header', () => {
|
|
53
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
54
|
+
const output = lastFrame();
|
|
55
|
+
expect(output).toContain('[1]Chat');
|
|
56
|
+
expect(output).toContain('[2]GitHub');
|
|
57
|
+
expect(output).toContain('[3]Agents');
|
|
58
|
+
expect(output).toContain('[4]Preview');
|
|
59
|
+
});
|
|
60
|
+
it('shows footer with keyboard hints', () => {
|
|
61
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
62
|
+
const output = lastFrame();
|
|
63
|
+
expect(output).toContain('Tab: cycle panes');
|
|
64
|
+
expect(output).toContain('Ctrl+Q: quit');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('pane sections', () => {
|
|
68
|
+
it('shows Chat pane', () => {
|
|
69
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
70
|
+
const output = lastFrame();
|
|
71
|
+
expect(output).toContain('Chat');
|
|
72
|
+
});
|
|
73
|
+
it('shows GitHub Issues pane', () => {
|
|
74
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
75
|
+
const output = lastFrame();
|
|
76
|
+
expect(output).toContain('GitHub Issues');
|
|
77
|
+
});
|
|
78
|
+
it('shows Agents pane', () => {
|
|
79
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
80
|
+
const output = lastFrame();
|
|
81
|
+
expect(output).toContain('Agents');
|
|
82
|
+
});
|
|
83
|
+
it('shows Status pane', () => {
|
|
84
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
85
|
+
const output = lastFrame();
|
|
86
|
+
expect(output).toContain('Status');
|
|
87
|
+
});
|
|
88
|
+
it('shows Preview pane', () => {
|
|
89
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
90
|
+
const output = lastFrame();
|
|
91
|
+
expect(output).toContain('Preview');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('keyboard navigation', () => {
|
|
95
|
+
it('switches to github pane when pressing 2', () => {
|
|
96
|
+
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
97
|
+
stdin.write('2');
|
|
98
|
+
const output = lastFrame();
|
|
99
|
+
// GitHub pane should now be active (shown by highlighting)
|
|
100
|
+
expect(output).toContain('GitHub');
|
|
101
|
+
});
|
|
102
|
+
it('switches to agents pane when pressing 3', () => {
|
|
103
|
+
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
104
|
+
stdin.write('3');
|
|
105
|
+
const output = lastFrame();
|
|
106
|
+
expect(output).toContain('Agents');
|
|
107
|
+
});
|
|
108
|
+
it('switches to preview pane when pressing 4', () => {
|
|
109
|
+
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
110
|
+
stdin.write('4');
|
|
111
|
+
const output = lastFrame();
|
|
112
|
+
expect(output).toContain('Preview');
|
|
113
|
+
});
|
|
114
|
+
it('switches back to chat pane when pressing 1', () => {
|
|
115
|
+
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
116
|
+
stdin.write('2'); // switch away
|
|
117
|
+
stdin.write('1'); // switch back
|
|
118
|
+
const output = lastFrame();
|
|
119
|
+
expect(output).toContain('Chat');
|
|
120
|
+
});
|
|
121
|
+
it('cycles panes with tab', () => {
|
|
122
|
+
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
123
|
+
// Tab key
|
|
124
|
+
stdin.write('\t');
|
|
125
|
+
const output = lastFrame();
|
|
126
|
+
// Should have cycled to next pane
|
|
127
|
+
expect(output).toBeDefined();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('TDD branding', () => {
|
|
131
|
+
it('shows TDD indicator in header', () => {
|
|
132
|
+
const { lastFrame } = render(_jsx(App, {}));
|
|
133
|
+
const output = lastFrame();
|
|
134
|
+
expect(output).toContain('TDD');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ChildProcess } from 'child_process';
|
|
2
|
+
type AgentStatus = 'idle' | 'working' | 'done' | 'error';
|
|
3
|
+
interface Agent {
|
|
4
|
+
id: number;
|
|
5
|
+
status: AgentStatus;
|
|
6
|
+
task: string | null;
|
|
7
|
+
issueNumber: number | null;
|
|
8
|
+
output: string[];
|
|
9
|
+
process: ChildProcess | null;
|
|
10
|
+
}
|
|
11
|
+
interface AgentsPaneProps {
|
|
12
|
+
isActive: boolean;
|
|
13
|
+
isTTY?: boolean;
|
|
14
|
+
onTaskComplete?: (issueNumber: number) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function AgentsPane({ isActive, isTTY, onTaskComplete }: AgentsPaneProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
export declare function getIdleAgent(agents: Agent[]): Agent | undefined;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
const MAX_AGENTS = 3;
|
|
7
|
+
export function AgentsPane({ isActive, isTTY = true, onTaskComplete }) {
|
|
8
|
+
const [agents, setAgents] = useState([
|
|
9
|
+
{ id: 1, status: 'idle', task: null, issueNumber: null, output: [], process: null },
|
|
10
|
+
{ id: 2, status: 'idle', task: null, issueNumber: null, output: [], process: null },
|
|
11
|
+
{ id: 3, status: 'idle', task: null, issueNumber: null, output: [], process: null },
|
|
12
|
+
]);
|
|
13
|
+
const assignTask = useCallback((agentId, task, issueNumber) => {
|
|
14
|
+
setAgents(prev => prev.map(agent => {
|
|
15
|
+
if (agent.id !== agentId)
|
|
16
|
+
return agent;
|
|
17
|
+
// Spawn Claude Code process for this task
|
|
18
|
+
const proc = spawn('claude', ['-p', `Work on task: ${task}. When done, output TASK_COMPLETE.`], {
|
|
19
|
+
cwd: process.cwd(),
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
21
|
+
});
|
|
22
|
+
const output = [];
|
|
23
|
+
proc.stdout?.on('data', (data) => {
|
|
24
|
+
const text = data.toString();
|
|
25
|
+
output.push(text);
|
|
26
|
+
// Check if task is complete
|
|
27
|
+
if (text.includes('TASK_COMPLETE')) {
|
|
28
|
+
setAgents(prev => prev.map(a => a.id === agentId ? { ...a, status: 'done', output } : a));
|
|
29
|
+
onTaskComplete?.(issueNumber);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
proc.stderr?.on('data', (data) => {
|
|
33
|
+
output.push(`[ERROR] ${data.toString()}`);
|
|
34
|
+
});
|
|
35
|
+
proc.on('close', (code) => {
|
|
36
|
+
setAgents(prev => prev.map(a => a.id === agentId ? {
|
|
37
|
+
...a,
|
|
38
|
+
status: code === 0 ? 'done' : 'error',
|
|
39
|
+
process: null
|
|
40
|
+
} : a));
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
...agent,
|
|
44
|
+
status: 'working',
|
|
45
|
+
task,
|
|
46
|
+
issueNumber,
|
|
47
|
+
output: [],
|
|
48
|
+
process: proc
|
|
49
|
+
};
|
|
50
|
+
}));
|
|
51
|
+
}, [onTaskComplete]);
|
|
52
|
+
const stopAgent = useCallback((agentId) => {
|
|
53
|
+
setAgents(prev => prev.map(agent => {
|
|
54
|
+
if (agent.id !== agentId)
|
|
55
|
+
return agent;
|
|
56
|
+
agent.process?.kill();
|
|
57
|
+
return { ...agent, status: 'idle', task: null, issueNumber: null, process: null };
|
|
58
|
+
}));
|
|
59
|
+
}, []);
|
|
60
|
+
useInput((input, key) => {
|
|
61
|
+
if (!isActive)
|
|
62
|
+
return;
|
|
63
|
+
// 1, 2, 3 to select agent
|
|
64
|
+
// s to stop selected agent
|
|
65
|
+
const agentNum = parseInt(input);
|
|
66
|
+
if (agentNum >= 1 && agentNum <= 3) {
|
|
67
|
+
const agent = agents[agentNum - 1];
|
|
68
|
+
if (agent.status === 'working') {
|
|
69
|
+
stopAgent(agentNum);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}, { isActive: isTTY });
|
|
73
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ["Agents (", agents.filter(a => a.status === 'working').length, "/", MAX_AGENTS, " active)"] }) }), agents.map((agent) => (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "gray", children: ["[", agent.id, "] "] }), agent.status === 'idle' && (_jsx(Text, { color: "gray", children: "Idle" })), agent.status === 'working' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { color: "cyan", children: [" #", agent.issueNumber, ": "] }), _jsxs(Text, { children: [agent.task?.slice(0, 30), "..."] })] })), agent.status === 'done' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", children: "Done " }), _jsxs(Text, { color: "gray", children: ["#", agent.issueNumber] })] })), agent.status === 'error' && (_jsx(Text, { color: "red", children: "Error" }))] }, agent.id))), isActive && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[1-3] Stop agent" }) }))] }));
|
|
74
|
+
}
|
|
75
|
+
export function getIdleAgent(agents) {
|
|
76
|
+
return agents.find(a => a.status === 'idle');
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { AgentsPane, getIdleAgent } from './AgentsPane.js';
|
|
5
|
+
describe('AgentsPane', () => {
|
|
6
|
+
describe('getIdleAgent', () => {
|
|
7
|
+
it('returns first idle agent', () => {
|
|
8
|
+
const agents = [
|
|
9
|
+
{ id: 1, status: 'working', task: 'task1', issueNumber: 1, output: [], process: null },
|
|
10
|
+
{ id: 2, status: 'idle', task: null, issueNumber: null, output: [], process: null },
|
|
11
|
+
{ id: 3, status: 'idle', task: null, issueNumber: null, output: [], process: null },
|
|
12
|
+
];
|
|
13
|
+
const idle = getIdleAgent(agents);
|
|
14
|
+
expect(idle?.id).toBe(2);
|
|
15
|
+
});
|
|
16
|
+
it('returns undefined when all agents busy', () => {
|
|
17
|
+
const agents = [
|
|
18
|
+
{ id: 1, status: 'working', task: 'task1', issueNumber: 1, output: [], process: null },
|
|
19
|
+
{ id: 2, status: 'working', task: 'task2', issueNumber: 2, output: [], process: null },
|
|
20
|
+
{ id: 3, status: 'done', task: 'task3', issueNumber: 3, output: [], process: null },
|
|
21
|
+
];
|
|
22
|
+
const idle = getIdleAgent(agents);
|
|
23
|
+
expect(idle).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
it('returns undefined for empty array', () => {
|
|
26
|
+
const idle = getIdleAgent([]);
|
|
27
|
+
expect(idle).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('component rendering', () => {
|
|
31
|
+
it('renders with all agents idle', () => {
|
|
32
|
+
const { lastFrame } = render(_jsx(AgentsPane, { isActive: false }));
|
|
33
|
+
const output = lastFrame();
|
|
34
|
+
expect(output).toContain('Agents');
|
|
35
|
+
expect(output).toContain('0/3 active');
|
|
36
|
+
expect(output).toContain('[1]');
|
|
37
|
+
expect(output).toContain('[2]');
|
|
38
|
+
expect(output).toContain('[3]');
|
|
39
|
+
expect(output).toContain('Idle');
|
|
40
|
+
});
|
|
41
|
+
it('shows controls when active', () => {
|
|
42
|
+
const { lastFrame } = render(_jsx(AgentsPane, { isActive: true }));
|
|
43
|
+
const output = lastFrame();
|
|
44
|
+
expect(output).toContain('[1-3] Stop agent');
|
|
45
|
+
});
|
|
46
|
+
it('hides controls when inactive', () => {
|
|
47
|
+
const { lastFrame } = render(_jsx(AgentsPane, { isActive: false }));
|
|
48
|
+
const output = lastFrame();
|
|
49
|
+
expect(output).not.toContain('[1-3] Stop agent');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { useState, useCallback } from 'react';
|
|
5
|
+
export function ChatPane({ isActive, isTTY = true }) {
|
|
6
|
+
const [messages, setMessages] = useState([
|
|
7
|
+
{ role: 'system', content: 'TDD Dashboard ready. Type a message to start.' }
|
|
8
|
+
]);
|
|
9
|
+
const [input, setInput] = useState('');
|
|
10
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
11
|
+
const sendMessage = useCallback(async (text) => {
|
|
12
|
+
if (!text.trim() || isLoading)
|
|
13
|
+
return;
|
|
14
|
+
// Add user message
|
|
15
|
+
setMessages(prev => [...prev, { role: 'user', content: text }]);
|
|
16
|
+
setInput('');
|
|
17
|
+
setIsLoading(true);
|
|
18
|
+
// TODO: Integrate with Claude Code CLI
|
|
19
|
+
// For now, simulate a response
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
setMessages(prev => [...prev, {
|
|
22
|
+
role: 'assistant',
|
|
23
|
+
content: `Received: "${text}"\n\nClaude Code integration coming soon...`
|
|
24
|
+
}]);
|
|
25
|
+
setIsLoading(false);
|
|
26
|
+
}, 500);
|
|
27
|
+
}, [isLoading]);
|
|
28
|
+
const handleSubmit = useCallback((value) => {
|
|
29
|
+
sendMessage(value);
|
|
30
|
+
}, [sendMessage]);
|
|
31
|
+
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, padding: 1, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, overflowY: "hidden", children: [messages.slice(-10).map((msg, i) => (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: msg.role === 'user' ? 'green' :
|
|
32
|
+
msg.role === 'assistant' ? 'cyan' :
|
|
33
|
+
'gray', children: [msg.role === 'user' ? '> ' : msg.role === 'assistant' ? ' ' : '# ', msg.content] }) }, i))), isLoading && (_jsx(Text, { color: "yellow", children: "Thinking..." }))] }), _jsxs(Box, { borderStyle: "round", borderColor: isActive ? 'green' : 'gray', paddingX: 1, children: [_jsx(Text, { color: "green", children: "> " }), isTTY ? (_jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, focus: isActive, placeholder: "Type a message..." })) : (_jsx(Text, { color: "gray", children: "Input disabled (no TTY)" }))] })] }));
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { ChatPane } from './ChatPane.js';
|
|
5
|
+
describe('ChatPane', () => {
|
|
6
|
+
describe('initial render', () => {
|
|
7
|
+
it('shows welcome message', () => {
|
|
8
|
+
const { lastFrame } = render(_jsx(ChatPane, { isActive: false }));
|
|
9
|
+
const output = lastFrame();
|
|
10
|
+
expect(output).toContain('TDD Dashboard ready');
|
|
11
|
+
});
|
|
12
|
+
it('shows input prompt', () => {
|
|
13
|
+
const { lastFrame } = render(_jsx(ChatPane, { isActive: false }));
|
|
14
|
+
const output = lastFrame();
|
|
15
|
+
expect(output).toContain('>');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe('active state', () => {
|
|
19
|
+
it('renders without error when active', () => {
|
|
20
|
+
const { lastFrame } = render(_jsx(ChatPane, { isActive: true }));
|
|
21
|
+
expect(lastFrame()).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
it('renders without error when inactive', () => {
|
|
24
|
+
const { lastFrame } = render(_jsx(ChatPane, { isActive: false }));
|
|
25
|
+
expect(lastFrame()).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('message display', () => {
|
|
29
|
+
it('shows system messages with # prefix', () => {
|
|
30
|
+
const { lastFrame } = render(_jsx(ChatPane, { isActive: false }));
|
|
31
|
+
const output = lastFrame();
|
|
32
|
+
// System message has # prefix
|
|
33
|
+
expect(output).toContain('#');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface Issue {
|
|
2
|
+
number: number;
|
|
3
|
+
title: string;
|
|
4
|
+
state: 'open' | 'closed';
|
|
5
|
+
labels: string[];
|
|
6
|
+
assignee: string | null;
|
|
7
|
+
}
|
|
8
|
+
interface GitHubPaneProps {
|
|
9
|
+
isActive: boolean;
|
|
10
|
+
isTTY?: boolean;
|
|
11
|
+
onAssignToAgent?: (issue: Issue) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function GitHubPane({ isActive, isTTY, onAssignToAgent }: GitHubPaneProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export declare function syncTaskToGitHub(title: string, body: string): Promise<number | null>;
|
|
15
|
+
export declare function markIssueComplete(number: number): Promise<void>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { exec } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
export function GitHubPane({ isActive, isTTY = true, onAssignToAgent }) {
|
|
8
|
+
const [issues, setIssues] = useState([]);
|
|
9
|
+
const [loading, setLoading] = useState(true);
|
|
10
|
+
const [error, setError] = useState(null);
|
|
11
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
12
|
+
const fetchIssues = useCallback(async () => {
|
|
13
|
+
try {
|
|
14
|
+
// Use gh CLI to fetch issues
|
|
15
|
+
const { stdout } = await execAsync('gh issue list --label "tdd" --state open --json number,title,state,labels,assignee --limit 20', { cwd: process.cwd() });
|
|
16
|
+
const parsed = JSON.parse(stdout || '[]');
|
|
17
|
+
setIssues(parsed.map((i) => ({
|
|
18
|
+
number: i.number,
|
|
19
|
+
title: i.title,
|
|
20
|
+
state: i.state,
|
|
21
|
+
labels: i.labels?.map((l) => l.name) || [],
|
|
22
|
+
assignee: i.assignee?.login || null
|
|
23
|
+
})));
|
|
24
|
+
setError(null);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
// Try without label filter
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await execAsync('gh issue list --state open --json number,title,state,labels,assignee --limit 10', { cwd: process.cwd() });
|
|
30
|
+
const parsed = JSON.parse(stdout || '[]');
|
|
31
|
+
setIssues(parsed.map((i) => ({
|
|
32
|
+
number: i.number,
|
|
33
|
+
title: i.title,
|
|
34
|
+
state: i.state,
|
|
35
|
+
labels: i.labels?.map((l) => l.name) || [],
|
|
36
|
+
assignee: i.assignee?.login || null
|
|
37
|
+
})));
|
|
38
|
+
setError(null);
|
|
39
|
+
}
|
|
40
|
+
catch (e2) {
|
|
41
|
+
setError('gh CLI not available or not in a repo');
|
|
42
|
+
setIssues([]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
setLoading(false);
|
|
46
|
+
}, []);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
fetchIssues();
|
|
49
|
+
const interval = setInterval(fetchIssues, 30000); // Refresh every 30s
|
|
50
|
+
return () => clearInterval(interval);
|
|
51
|
+
}, [fetchIssues]);
|
|
52
|
+
const createIssue = useCallback(async (title, body) => {
|
|
53
|
+
try {
|
|
54
|
+
await execAsync(`gh issue create --title "${title}" --body "${body}" --label "tdd"`, { cwd: process.cwd() });
|
|
55
|
+
fetchIssues();
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
setError('Failed to create issue');
|
|
59
|
+
}
|
|
60
|
+
}, [fetchIssues]);
|
|
61
|
+
const closeIssue = useCallback(async (number) => {
|
|
62
|
+
try {
|
|
63
|
+
await execAsync(`gh issue close ${number}`, { cwd: process.cwd() });
|
|
64
|
+
fetchIssues();
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
setError('Failed to close issue');
|
|
68
|
+
}
|
|
69
|
+
}, [fetchIssues]);
|
|
70
|
+
useInput((input, key) => {
|
|
71
|
+
if (!isActive)
|
|
72
|
+
return;
|
|
73
|
+
if (key.upArrow && selectedIndex > 0) {
|
|
74
|
+
setSelectedIndex(prev => prev - 1);
|
|
75
|
+
}
|
|
76
|
+
if (key.downArrow && selectedIndex < issues.length - 1) {
|
|
77
|
+
setSelectedIndex(prev => prev + 1);
|
|
78
|
+
}
|
|
79
|
+
if (input === 'a' && issues[selectedIndex]) {
|
|
80
|
+
onAssignToAgent?.(issues[selectedIndex]);
|
|
81
|
+
}
|
|
82
|
+
if (input === 'c' && issues[selectedIndex]) {
|
|
83
|
+
closeIssue(issues[selectedIndex].number);
|
|
84
|
+
}
|
|
85
|
+
if (input === 'r') {
|
|
86
|
+
setLoading(true);
|
|
87
|
+
fetchIssues();
|
|
88
|
+
}
|
|
89
|
+
}, { isActive: isTTY });
|
|
90
|
+
if (loading) {
|
|
91
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "gray", children: "Loading issues..." }) }));
|
|
92
|
+
}
|
|
93
|
+
if (error) {
|
|
94
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", children: error }), _jsx(Text, { dimColor: true, children: "Make sure gh CLI is installed and authenticated." })] }));
|
|
95
|
+
}
|
|
96
|
+
if (issues.length === 0) {
|
|
97
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No open issues." }), _jsx(Text, { dimColor: true, children: "Create issues with 'gh issue create'" })] }));
|
|
98
|
+
}
|
|
99
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [issues.slice(0, 6).map((issue, idx) => (_jsxs(Box, { children: [_jsx(Text, { color: idx === selectedIndex && isActive ? 'cyan' : 'white', children: idx === selectedIndex && isActive ? '> ' : ' ' }), _jsxs(Text, { color: "green", children: ["#", issue.number, " "] }), _jsxs(Text, { children: [issue.title.slice(0, 35), issue.title.length > 35 ? '...' : ''] }), issue.labels.includes('in-progress') && (_jsx(Text, { color: "yellow", children: " [WIP]" }))] }, issue.number))), isActive && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "[a] Assign to agent | [c] Close | [r] Refresh" }) }))] }));
|
|
100
|
+
}
|
|
101
|
+
// Helper to sync a task to GitHub
|
|
102
|
+
export async function syncTaskToGitHub(title, body) {
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await promisify(exec)(`gh issue create --title "${title}" --body "${body}" --label "tdd" --json number`, { cwd: process.cwd() });
|
|
105
|
+
const parsed = JSON.parse(stdout);
|
|
106
|
+
return parsed.number;
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function markIssueComplete(number) {
|
|
113
|
+
try {
|
|
114
|
+
await promisify(exec)(`gh issue close ${number} --comment "Completed by TDD agent"`, {
|
|
115
|
+
cwd: process.cwd()
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
// Ignore errors
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { GitHubPane } from './GitHubPane.js';
|
|
5
|
+
// Mock child_process to avoid actual gh calls
|
|
6
|
+
vi.mock('child_process', () => ({
|
|
7
|
+
exec: vi.fn((cmd, opts, cb) => {
|
|
8
|
+
if (typeof opts === 'function') {
|
|
9
|
+
cb = opts;
|
|
10
|
+
}
|
|
11
|
+
// Simulate gh CLI not available by default
|
|
12
|
+
const error = new Error('gh not found');
|
|
13
|
+
if (cb)
|
|
14
|
+
cb(error, '', '');
|
|
15
|
+
return { stdout: null, stderr: null };
|
|
16
|
+
}),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('util', async () => {
|
|
19
|
+
const actual = await vi.importActual('util');
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
promisify: (fn) => async (...args) => {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
fn(...args, (err, stdout, stderr) => {
|
|
25
|
+
if (err)
|
|
26
|
+
reject(err);
|
|
27
|
+
else
|
|
28
|
+
resolve({ stdout, stderr });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
describe('GitHubPane', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
describe('loading state', () => {
|
|
39
|
+
it('shows loading message initially', () => {
|
|
40
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: false }));
|
|
41
|
+
const output = lastFrame();
|
|
42
|
+
expect(output).toContain('Loading issues...');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('error state', () => {
|
|
46
|
+
it('shows error when gh CLI fails', async () => {
|
|
47
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: false }));
|
|
48
|
+
// Wait for effect to run
|
|
49
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
50
|
+
const output = lastFrame();
|
|
51
|
+
expect(output).toContain('gh CLI not available');
|
|
52
|
+
});
|
|
53
|
+
it('shows install hint on error', async () => {
|
|
54
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: false }));
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
56
|
+
const output = lastFrame();
|
|
57
|
+
expect(output).toContain('Make sure gh CLI is installed');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('controls', () => {
|
|
61
|
+
it('shows controls when active and has issues', async () => {
|
|
62
|
+
// This test would need proper mocking of successful gh response
|
|
63
|
+
// For now, we test that the component doesn't crash
|
|
64
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: true }));
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
66
|
+
const output = lastFrame();
|
|
67
|
+
// Either shows controls or error state
|
|
68
|
+
expect(output).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('callback', () => {
|
|
72
|
+
it('accepts onAssignToAgent callback', () => {
|
|
73
|
+
const mockCallback = vi.fn();
|
|
74
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: true, onAssignToAgent: mockCallback }));
|
|
75
|
+
// Component should render without error
|
|
76
|
+
expect(lastFrame()).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|