tycono 0.1.95 → 0.1.96-beta.1
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/bin/tycono.ts +63 -0
- package/package.json +8 -1
- package/src/tui/api.ts +241 -0
- package/src/tui/app.tsx +230 -0
- package/src/tui/components/CommandInput.tsx +32 -0
- package/src/tui/components/HelpOverlay.tsx +51 -0
- package/src/tui/components/OrgTree.tsx +119 -0
- package/src/tui/components/SessionList.tsx +74 -0
- package/src/tui/components/StatusBar.tsx +49 -0
- package/src/tui/components/StreamPanel.tsx +182 -0
- package/src/tui/components/WaveDialog.tsx +56 -0
- package/src/tui/hooks/useApi.ts +74 -0
- package/src/tui/hooks/useKeyboard.ts +62 -0
- package/src/tui/hooks/useSSE.ts +69 -0
- package/src/tui/index.tsx +25 -0
- package/src/tui/store.ts +99 -0
- package/src/tui/theme.ts +78 -0
package/bin/tycono.ts
CHANGED
|
@@ -20,6 +20,8 @@ function printHelp(): void {
|
|
|
20
20
|
|
|
21
21
|
Usage:
|
|
22
22
|
tycono [path] Start the server (optionally point to a company directory)
|
|
23
|
+
tycono tui Start API server + TUI mode
|
|
24
|
+
tycono tui --attach Connect TUI to existing API server
|
|
23
25
|
tycono --help Show this help message
|
|
24
26
|
tycono --version Show version
|
|
25
27
|
|
|
@@ -27,6 +29,8 @@ function printHelp(): void {
|
|
|
27
29
|
tycono Start in current directory
|
|
28
30
|
tycono ./my-company Start with existing company folder
|
|
29
31
|
tycono /path/to/akb Start with absolute path
|
|
32
|
+
tycono tui Start with terminal UI
|
|
33
|
+
PORT=3000 tycono tui --attach Attach TUI to running server
|
|
30
34
|
|
|
31
35
|
AI Engine (auto-detected):
|
|
32
36
|
1. Claude Code CLI Install from https://claude.ai/download (recommended)
|
|
@@ -190,6 +194,49 @@ async function startServer(): Promise<void> {
|
|
|
190
194
|
process.on('SIGTERM', shutdown);
|
|
191
195
|
}
|
|
192
196
|
|
|
197
|
+
async function startServerForTui(): Promise<void> {
|
|
198
|
+
// Load .env from current directory
|
|
199
|
+
const dotenvPath = path.resolve(process.cwd(), '.env');
|
|
200
|
+
if (fs.existsSync(dotenvPath)) {
|
|
201
|
+
const { config } = await import('dotenv');
|
|
202
|
+
config({ path: dotenvPath });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!process.env.COMPANY_ROOT) {
|
|
206
|
+
process.env.COMPANY_ROOT = process.cwd();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
process.env.NODE_ENV = 'production';
|
|
210
|
+
const auth = detectAuth();
|
|
211
|
+
process.env.EXECUTION_ENGINE = auth.engine === 'claude-cli' ? 'claude-cli' : auth.engine === 'direct-api' ? 'direct-api' : 'none';
|
|
212
|
+
|
|
213
|
+
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort();
|
|
214
|
+
process.env.PORT = String(port);
|
|
215
|
+
|
|
216
|
+
const { createHttpServer } = await import('../src/api/src/create-server.js');
|
|
217
|
+
const server = createHttpServer();
|
|
218
|
+
|
|
219
|
+
const host = process.env.HOST || '0.0.0.0';
|
|
220
|
+
|
|
221
|
+
await new Promise<void>((resolve) => {
|
|
222
|
+
server.listen(port, host, () => resolve());
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
console.log(` API server started on port ${port}`);
|
|
226
|
+
|
|
227
|
+
// Graceful shutdown
|
|
228
|
+
const shutdown = () => {
|
|
229
|
+
server.close(() => process.exit(0));
|
|
230
|
+
setTimeout(() => process.exit(1), 5000);
|
|
231
|
+
};
|
|
232
|
+
process.on('SIGINT', shutdown);
|
|
233
|
+
process.on('SIGTERM', shutdown);
|
|
234
|
+
|
|
235
|
+
// Start TUI
|
|
236
|
+
const { startTui } = await import('../src/tui/index.tsx');
|
|
237
|
+
await startTui({ port });
|
|
238
|
+
}
|
|
239
|
+
|
|
193
240
|
export async function main(args: string[]): Promise<void> {
|
|
194
241
|
const command = args[0];
|
|
195
242
|
|
|
@@ -203,6 +250,22 @@ export async function main(args: string[]): Promise<void> {
|
|
|
203
250
|
return;
|
|
204
251
|
}
|
|
205
252
|
|
|
253
|
+
// tui subcommand: start API server + TUI mode
|
|
254
|
+
if (command === 'tui') {
|
|
255
|
+
const attachMode = args.includes('--attach');
|
|
256
|
+
// If --attach, skip server start — just connect to existing API
|
|
257
|
+
if (attachMode) {
|
|
258
|
+
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
|
259
|
+
const { startTui } = await import('../src/tui/index.tsx');
|
|
260
|
+
await startTui({ port });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Start API server, then TUI
|
|
265
|
+
await startServerForTui();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
206
269
|
if (command && !command.startsWith('-')) {
|
|
207
270
|
// Treat as path to company directory
|
|
208
271
|
const resolved = path.resolve(command);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tycono",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.96-beta.1",
|
|
4
4
|
"description": "Build an AI company. Watch them work.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"src/api/src/",
|
|
12
12
|
"src/api/package.json",
|
|
13
13
|
"src/shared/",
|
|
14
|
+
"src/tui/",
|
|
14
15
|
"src/web/dist/",
|
|
15
16
|
"templates/"
|
|
16
17
|
],
|
|
@@ -36,7 +37,12 @@
|
|
|
36
37
|
"express": "^5.0.1",
|
|
37
38
|
"glob": "^11.0.1",
|
|
38
39
|
"gray-matter": "^4.0.3",
|
|
40
|
+
"ink": "^5.2.1",
|
|
41
|
+
"ink-select-input": "^6.2.0",
|
|
42
|
+
"ink-spinner": "^5.0.0",
|
|
43
|
+
"ink-text-input": "^6.0.0",
|
|
39
44
|
"marked": "^15.0.6",
|
|
45
|
+
"react": "^18.3.1",
|
|
40
46
|
"tsx": "^4.19.3",
|
|
41
47
|
"tyconoforge": "^0.1.0-beta.0",
|
|
42
48
|
"yaml": "^2.7.0"
|
|
@@ -45,6 +51,7 @@
|
|
|
45
51
|
"@types/cors": "^2.8.17",
|
|
46
52
|
"@types/express": "^5.0.0",
|
|
47
53
|
"@types/node": "^22.13.4",
|
|
54
|
+
"@types/react": "^19.2.14",
|
|
48
55
|
"tsup": "^8.5.1",
|
|
49
56
|
"typescript": "^5.7.3"
|
|
50
57
|
},
|
package/src/tui/api.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI API Client — HTTP + SSE for communicating with Tycono API server
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
|
|
7
|
+
let BASE_URL = 'http://localhost:3000';
|
|
8
|
+
|
|
9
|
+
export function setBaseUrl(url: string): void {
|
|
10
|
+
BASE_URL = url.replace(/\/$/, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getBaseUrl(): string {
|
|
14
|
+
return BASE_URL;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* ─── HTTP helpers ─── */
|
|
18
|
+
|
|
19
|
+
async function fetchJson<T>(path: string, options?: { method?: string; body?: unknown }): Promise<T> {
|
|
20
|
+
const url = `${BASE_URL}${path}`;
|
|
21
|
+
const method = options?.method ?? 'GET';
|
|
22
|
+
const bodyStr = options?.body ? JSON.stringify(options.body) : undefined;
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const urlObj = new URL(url);
|
|
26
|
+
const req = http.request(
|
|
27
|
+
{
|
|
28
|
+
hostname: urlObj.hostname,
|
|
29
|
+
port: urlObj.port,
|
|
30
|
+
path: urlObj.pathname + urlObj.search,
|
|
31
|
+
method,
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
...(bodyStr ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {}),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
(res) => {
|
|
38
|
+
let data = '';
|
|
39
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
40
|
+
res.on('end', () => {
|
|
41
|
+
try {
|
|
42
|
+
resolve(JSON.parse(data) as T);
|
|
43
|
+
} catch {
|
|
44
|
+
reject(new Error(`Invalid JSON from ${path}: ${data.slice(0, 200)}`));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
req.on('error', reject);
|
|
50
|
+
if (bodyStr) req.write(bodyStr);
|
|
51
|
+
req.end();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ─── API Types ─── */
|
|
56
|
+
|
|
57
|
+
export interface RoleInfo {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
level: string;
|
|
61
|
+
reportsTo: string;
|
|
62
|
+
status: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CompanyInfo {
|
|
66
|
+
name: string;
|
|
67
|
+
domain: string;
|
|
68
|
+
founded: string;
|
|
69
|
+
mission: string;
|
|
70
|
+
roles: RoleInfo[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SessionInfo {
|
|
74
|
+
id: string;
|
|
75
|
+
roleId: string;
|
|
76
|
+
title: string;
|
|
77
|
+
mode: string;
|
|
78
|
+
status: string;
|
|
79
|
+
source: string;
|
|
80
|
+
waveId?: string;
|
|
81
|
+
createdAt: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ExecStatus {
|
|
85
|
+
statuses: Record<string, string>;
|
|
86
|
+
activeExecutions: Array<{
|
|
87
|
+
id: string;
|
|
88
|
+
roleId: string;
|
|
89
|
+
task: string;
|
|
90
|
+
startedAt: string;
|
|
91
|
+
}>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface WaveResponse {
|
|
95
|
+
waveId: string;
|
|
96
|
+
supervisorSessionId?: string;
|
|
97
|
+
mode: string;
|
|
98
|
+
directive: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SSEEvent {
|
|
102
|
+
seq: number;
|
|
103
|
+
ts: string;
|
|
104
|
+
type: string;
|
|
105
|
+
roleId: string;
|
|
106
|
+
data: Record<string, unknown>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ─── API calls ─── */
|
|
110
|
+
|
|
111
|
+
export async function fetchCompany(): Promise<CompanyInfo> {
|
|
112
|
+
return fetchJson<CompanyInfo>('/api/company');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function fetchRoles(): Promise<RoleInfo[]> {
|
|
116
|
+
return fetchJson<RoleInfo[]>('/api/roles');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function fetchSessions(): Promise<SessionInfo[]> {
|
|
120
|
+
return fetchJson<SessionInfo[]>('/api/sessions');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function fetchExecStatus(): Promise<ExecStatus> {
|
|
124
|
+
return fetchJson<ExecStatus>('/api/exec/status');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function dispatchWave(directive: string, options?: {
|
|
128
|
+
targetRoles?: string[];
|
|
129
|
+
continuous?: boolean;
|
|
130
|
+
}): Promise<WaveResponse> {
|
|
131
|
+
return fetchJson<WaveResponse>('/api/jobs', {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
body: {
|
|
134
|
+
type: 'wave',
|
|
135
|
+
directive,
|
|
136
|
+
targetRoles: options?.targetRoles,
|
|
137
|
+
continuous: options?.continuous ?? false,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function sendDirective(waveId: string, text: string): Promise<{ ok: boolean }> {
|
|
143
|
+
return fetchJson<{ ok: boolean }>(`/api/waves/${waveId}/directive`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
body: { text },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function fetchActiveWaves(): Promise<{ waves: Array<{ waveId: string; sessionIds: string[] }> }> {
|
|
150
|
+
return fetchJson('/api/waves/active');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ─── SSE stream ─── */
|
|
154
|
+
|
|
155
|
+
export interface SSEConnection {
|
|
156
|
+
close(): void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function subscribeToWaveStream(
|
|
160
|
+
waveId: string,
|
|
161
|
+
onEvent: (event: SSEEvent) => void,
|
|
162
|
+
onEnd?: (reason: string) => void,
|
|
163
|
+
fromSeq?: number,
|
|
164
|
+
): SSEConnection {
|
|
165
|
+
const url = new URL(`${BASE_URL}/api/waves/${waveId}/stream`);
|
|
166
|
+
if (fromSeq) url.searchParams.set('from', String(fromSeq));
|
|
167
|
+
|
|
168
|
+
let destroyed = false;
|
|
169
|
+
let req: http.ClientRequest | null = null;
|
|
170
|
+
|
|
171
|
+
const connect = () => {
|
|
172
|
+
req = http.get(url.toString(), (res) => {
|
|
173
|
+
let buffer = '';
|
|
174
|
+
|
|
175
|
+
res.on('data', (chunk: Buffer) => {
|
|
176
|
+
if (destroyed) return;
|
|
177
|
+
buffer += chunk.toString();
|
|
178
|
+
|
|
179
|
+
// Parse SSE format
|
|
180
|
+
const parts = buffer.split('\n\n');
|
|
181
|
+
buffer = parts.pop() ?? '';
|
|
182
|
+
|
|
183
|
+
for (const part of parts) {
|
|
184
|
+
if (!part.trim() || part.startsWith(':')) continue;
|
|
185
|
+
|
|
186
|
+
const lines = part.split('\n');
|
|
187
|
+
let eventType = '';
|
|
188
|
+
let data = '';
|
|
189
|
+
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
if (line.startsWith('event: ')) {
|
|
192
|
+
eventType = line.slice(7);
|
|
193
|
+
} else if (line.startsWith('data: ')) {
|
|
194
|
+
data = line.slice(6);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (eventType === 'activity' && data) {
|
|
199
|
+
try {
|
|
200
|
+
onEvent(JSON.parse(data) as SSEEvent);
|
|
201
|
+
} catch { /* ignore parse errors */ }
|
|
202
|
+
} else if (eventType === 'stream:end' && data) {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(data);
|
|
205
|
+
onEnd?.(parsed.reason ?? 'unknown');
|
|
206
|
+
} catch {
|
|
207
|
+
onEnd?.('unknown');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
res.on('end', () => {
|
|
214
|
+
if (!destroyed) {
|
|
215
|
+
onEnd?.('disconnected');
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
res.on('error', () => {
|
|
220
|
+
if (!destroyed) {
|
|
221
|
+
onEnd?.('error');
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
req.on('error', () => {
|
|
227
|
+
if (!destroyed) {
|
|
228
|
+
onEnd?.('error');
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
connect();
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
close() {
|
|
237
|
+
destroyed = true;
|
|
238
|
+
req?.destroy();
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/tui/app.tsx
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI App — main layout with 4 panels
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* ┌─────────────────────────────────────────┐
|
|
6
|
+
* │ StatusBar │
|
|
7
|
+
* ├──────────────┬──────────────────────────┤
|
|
8
|
+
* │ OrgTree │ StreamPanel │
|
|
9
|
+
* │ │ │
|
|
10
|
+
* ├──────────────┤ │
|
|
11
|
+
* │ SessionList │ │
|
|
12
|
+
* ├──────────────┴──────────────────────────┤
|
|
13
|
+
* │ CommandInput │
|
|
14
|
+
* └─────────────────────────────────────────┘
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
18
|
+
import { Box, Text, useApp } from 'ink';
|
|
19
|
+
import { StatusBar } from './components/StatusBar';
|
|
20
|
+
import { OrgTree } from './components/OrgTree';
|
|
21
|
+
import { SessionList } from './components/SessionList';
|
|
22
|
+
import { StreamPanel } from './components/StreamPanel';
|
|
23
|
+
import { CommandInput } from './components/CommandInput';
|
|
24
|
+
import { WaveDialog } from './components/WaveDialog';
|
|
25
|
+
import { HelpOverlay } from './components/HelpOverlay';
|
|
26
|
+
import { useApi } from './hooks/useApi';
|
|
27
|
+
import { useSSE } from './hooks/useSSE';
|
|
28
|
+
import { useKeyboard } from './hooks/useKeyboard';
|
|
29
|
+
import { buildOrgTree } from './store';
|
|
30
|
+
import { dispatchWave } from './api';
|
|
31
|
+
|
|
32
|
+
type Panel = 'org' | 'sessions' | 'stream' | 'command';
|
|
33
|
+
type Dialog = 'none' | 'wave' | 'help';
|
|
34
|
+
|
|
35
|
+
const PANELS: Panel[] = ['org', 'sessions', 'stream', 'command'];
|
|
36
|
+
|
|
37
|
+
export const App: React.FC = () => {
|
|
38
|
+
const { exit } = useApp();
|
|
39
|
+
const api = useApi();
|
|
40
|
+
|
|
41
|
+
const [activePanel, setActivePanel] = useState<Panel>('org');
|
|
42
|
+
const [dialog, setDialog] = useState<Dialog>('none');
|
|
43
|
+
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
|
44
|
+
const [selectedSessionIndex, setSelectedSessionIndex] = useState(0);
|
|
45
|
+
const [waveId, setWaveId] = useState<string | null>(null);
|
|
46
|
+
const [waveStatus, setWaveStatus] = useState<'idle' | 'running' | 'done'>('idle');
|
|
47
|
+
|
|
48
|
+
// Derive active wave from API if we don't have one
|
|
49
|
+
const effectiveWaveId = waveId ?? api.activeWaveId;
|
|
50
|
+
|
|
51
|
+
const sse = useSSE(effectiveWaveId);
|
|
52
|
+
|
|
53
|
+
// Build org tree
|
|
54
|
+
const roles = api.company?.roles ?? [];
|
|
55
|
+
const flatRoleIds = useMemo(() => roles.map(r => r.id), [roles]);
|
|
56
|
+
const statuses = api.execStatus?.statuses ?? {};
|
|
57
|
+
const orgTree = useMemo(() => buildOrgTree(roles, statuses), [roles, statuses]);
|
|
58
|
+
|
|
59
|
+
// Count active
|
|
60
|
+
const activeCount = Object.values(statuses).filter(s => s === 'working' || s === 'streaming').length;
|
|
61
|
+
|
|
62
|
+
// Determine wave status from SSE
|
|
63
|
+
const derivedWaveStatus = useMemo(() => {
|
|
64
|
+
if (sse.streamStatus === 'streaming') return 'running' as const;
|
|
65
|
+
if (sse.streamStatus === 'done') return 'done' as const;
|
|
66
|
+
if (waveStatus === 'running' && activeCount > 0) return 'running' as const;
|
|
67
|
+
return waveStatus;
|
|
68
|
+
}, [sse.streamStatus, waveStatus, activeCount]);
|
|
69
|
+
|
|
70
|
+
// Handle wave dispatch
|
|
71
|
+
const handleWaveSubmit = useCallback(async (directive: string) => {
|
|
72
|
+
setDialog('none');
|
|
73
|
+
try {
|
|
74
|
+
const result = await dispatchWave(directive);
|
|
75
|
+
setWaveId(result.waveId);
|
|
76
|
+
setWaveStatus('running');
|
|
77
|
+
sse.clearEvents();
|
|
78
|
+
api.refresh();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Show error briefly
|
|
81
|
+
console.error('Wave dispatch failed:', err);
|
|
82
|
+
}
|
|
83
|
+
}, [sse, api]);
|
|
84
|
+
|
|
85
|
+
// Keyboard actions — disabled when dialog is open
|
|
86
|
+
const keyboardEnabled = dialog === 'none';
|
|
87
|
+
|
|
88
|
+
useKeyboard({
|
|
89
|
+
onWave: () => setDialog('wave'),
|
|
90
|
+
onQuit: () => exit(),
|
|
91
|
+
onHelp: () => setDialog(dialog === 'help' ? 'none' : 'help'),
|
|
92
|
+
onTab: () => {
|
|
93
|
+
const idx = PANELS.indexOf(activePanel);
|
|
94
|
+
setActivePanel(PANELS[(idx + 1) % PANELS.length]);
|
|
95
|
+
},
|
|
96
|
+
onUp: () => {
|
|
97
|
+
if (activePanel === 'org') {
|
|
98
|
+
setSelectedRoleIndex(Math.max(0, selectedRoleIndex - 1));
|
|
99
|
+
} else if (activePanel === 'sessions') {
|
|
100
|
+
setSelectedSessionIndex(Math.max(0, selectedSessionIndex - 1));
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
onDown: () => {
|
|
104
|
+
if (activePanel === 'org') {
|
|
105
|
+
setSelectedRoleIndex(Math.min(flatRoleIds.length - 1, selectedRoleIndex + 1));
|
|
106
|
+
} else if (activePanel === 'sessions') {
|
|
107
|
+
setSelectedSessionIndex(Math.min(Math.max(0, api.sessions.length - 1), selectedSessionIndex + 1));
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
onEnter: () => {
|
|
111
|
+
// Future: select role/session to show in stream
|
|
112
|
+
},
|
|
113
|
+
onEscape: () => {
|
|
114
|
+
if (dialog !== 'none') {
|
|
115
|
+
setDialog('none');
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}, keyboardEnabled);
|
|
119
|
+
|
|
120
|
+
// Error display
|
|
121
|
+
if (api.error) {
|
|
122
|
+
return (
|
|
123
|
+
<Box flexDirection="column" paddingX={1}>
|
|
124
|
+
<Text color="cyan" bold>TYCONO TUI</Text>
|
|
125
|
+
<Text color="red">API Error: {api.error}</Text>
|
|
126
|
+
<Text color="gray">Make sure the API server is running on the configured port.</Text>
|
|
127
|
+
<Text color="gray" dimColor>Press q to quit</Text>
|
|
128
|
+
</Box>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Help overlay
|
|
133
|
+
if (dialog === 'help') {
|
|
134
|
+
return (
|
|
135
|
+
<Box flexDirection="column">
|
|
136
|
+
<StatusBar
|
|
137
|
+
companyName={api.company?.name ?? 'Loading...'}
|
|
138
|
+
waveId={effectiveWaveId}
|
|
139
|
+
waveStatus={derivedWaveStatus}
|
|
140
|
+
activeCount={activeCount}
|
|
141
|
+
totalCost={0}
|
|
142
|
+
/>
|
|
143
|
+
<HelpOverlay onClose={() => setDialog('none')} />
|
|
144
|
+
</Box>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Wave dialog
|
|
149
|
+
if (dialog === 'wave') {
|
|
150
|
+
return (
|
|
151
|
+
<Box flexDirection="column">
|
|
152
|
+
<StatusBar
|
|
153
|
+
companyName={api.company?.name ?? 'Loading...'}
|
|
154
|
+
waveId={effectiveWaveId}
|
|
155
|
+
waveStatus={derivedWaveStatus}
|
|
156
|
+
activeCount={activeCount}
|
|
157
|
+
totalCost={0}
|
|
158
|
+
/>
|
|
159
|
+
<WaveDialog
|
|
160
|
+
onSubmit={handleWaveSubmit}
|
|
161
|
+
onCancel={() => setDialog('none')}
|
|
162
|
+
/>
|
|
163
|
+
</Box>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Box flexDirection="column">
|
|
169
|
+
{/* Status Bar */}
|
|
170
|
+
<StatusBar
|
|
171
|
+
companyName={api.company?.name ?? 'Loading...'}
|
|
172
|
+
waveId={effectiveWaveId}
|
|
173
|
+
waveStatus={derivedWaveStatus}
|
|
174
|
+
activeCount={activeCount}
|
|
175
|
+
totalCost={0}
|
|
176
|
+
/>
|
|
177
|
+
|
|
178
|
+
{/* Separator */}
|
|
179
|
+
<Box width="100%">
|
|
180
|
+
<Text color="gray">{'\u2500'.repeat(70)}</Text>
|
|
181
|
+
</Box>
|
|
182
|
+
|
|
183
|
+
{/* Main content: left (org + sessions) | right (stream) */}
|
|
184
|
+
<Box flexGrow={1}>
|
|
185
|
+
{/* Left column */}
|
|
186
|
+
<Box flexDirection="column" width={28}>
|
|
187
|
+
<OrgTree
|
|
188
|
+
tree={orgTree}
|
|
189
|
+
focused={activePanel === 'org'}
|
|
190
|
+
selectedIndex={selectedRoleIndex}
|
|
191
|
+
flatRoles={flatRoleIds}
|
|
192
|
+
/>
|
|
193
|
+
<Box marginTop={1}>
|
|
194
|
+
<SessionList
|
|
195
|
+
sessions={api.sessions}
|
|
196
|
+
focused={activePanel === 'sessions'}
|
|
197
|
+
selectedIndex={selectedSessionIndex}
|
|
198
|
+
/>
|
|
199
|
+
</Box>
|
|
200
|
+
</Box>
|
|
201
|
+
|
|
202
|
+
{/* Vertical separator */}
|
|
203
|
+
<Box flexDirection="column" marginX={0}>
|
|
204
|
+
<Text color="gray">{'\u2502\n'.repeat(15)}</Text>
|
|
205
|
+
</Box>
|
|
206
|
+
|
|
207
|
+
{/* Right column: Stream */}
|
|
208
|
+
<StreamPanel
|
|
209
|
+
events={sse.events}
|
|
210
|
+
allRoleIds={flatRoleIds}
|
|
211
|
+
focused={activePanel === 'stream'}
|
|
212
|
+
streamStatus={sse.streamStatus}
|
|
213
|
+
waveId={effectiveWaveId}
|
|
214
|
+
/>
|
|
215
|
+
</Box>
|
|
216
|
+
|
|
217
|
+
{/* Separator */}
|
|
218
|
+
<Box width="100%">
|
|
219
|
+
<Text color="gray">{'\u2500'.repeat(70)}</Text>
|
|
220
|
+
</Box>
|
|
221
|
+
|
|
222
|
+
{/* Command Input */}
|
|
223
|
+
<CommandInput
|
|
224
|
+
focused={activePanel === 'command'}
|
|
225
|
+
waveStatus={derivedWaveStatus}
|
|
226
|
+
dialog={dialog}
|
|
227
|
+
/>
|
|
228
|
+
</Box>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandInput — bottom bar with command input and shortcut hints
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { Box, Text } from 'ink';
|
|
7
|
+
|
|
8
|
+
interface CommandInputProps {
|
|
9
|
+
focused: boolean;
|
|
10
|
+
waveStatus: 'idle' | 'running' | 'done';
|
|
11
|
+
dialog: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const CommandInput: React.FC<CommandInputProps> = ({ focused, waveStatus, dialog }) => {
|
|
15
|
+
if (dialog !== 'none') return null;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Box width="100%" paddingX={1} justifyContent="space-between">
|
|
19
|
+
<Box>
|
|
20
|
+
<Text color="green" bold>> </Text>
|
|
21
|
+
<Text color={focused ? 'white' : 'gray'}>
|
|
22
|
+
{waveStatus === 'running' ? 'Wave running...' : 'Ready'}
|
|
23
|
+
</Text>
|
|
24
|
+
</Box>
|
|
25
|
+
<Box>
|
|
26
|
+
<Text color="gray" dimColor>
|
|
27
|
+
[w]ave [?]help [q]uit [Tab]panel
|
|
28
|
+
</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
</Box>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HelpOverlay — keyboard shortcut reference
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { Box, Text, useInput } from 'ink';
|
|
7
|
+
|
|
8
|
+
interface HelpOverlayProps {
|
|
9
|
+
onClose(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const shortcuts = [
|
|
13
|
+
['w', 'Wave dispatch'],
|
|
14
|
+
['q', 'Quit'],
|
|
15
|
+
['?', 'Toggle help'],
|
|
16
|
+
['Tab', 'Cycle panels'],
|
|
17
|
+
['j/k', 'Navigate (in focused panel)'],
|
|
18
|
+
['Enter', 'Select'],
|
|
19
|
+
['Esc', 'Close dialog / deselect'],
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export const HelpOverlay: React.FC<HelpOverlayProps> = ({ onClose }) => {
|
|
23
|
+
useInput((input, key) => {
|
|
24
|
+
if (input === '?' || input === 'q' || key.escape) {
|
|
25
|
+
onClose();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Box
|
|
31
|
+
flexDirection="column"
|
|
32
|
+
borderStyle="round"
|
|
33
|
+
borderColor="yellow"
|
|
34
|
+
paddingX={2}
|
|
35
|
+
paddingY={1}
|
|
36
|
+
>
|
|
37
|
+
<Text bold color="yellow">{'\u2500\u2500'} Keyboard Shortcuts {'\u2500\u2500'}</Text>
|
|
38
|
+
<Box marginTop={1} flexDirection="column">
|
|
39
|
+
{shortcuts.map(([key, desc]) => (
|
|
40
|
+
<Box key={key}>
|
|
41
|
+
<Text color="cyan" bold>{key.padEnd(8)}</Text>
|
|
42
|
+
<Text color="white">{desc}</Text>
|
|
43
|
+
</Box>
|
|
44
|
+
))}
|
|
45
|
+
</Box>
|
|
46
|
+
<Box marginTop={1}>
|
|
47
|
+
<Text color="gray" dimColor>Press ? or Esc to close</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
</Box>
|
|
50
|
+
);
|
|
51
|
+
};
|