tycono 0.1.96-beta.2 → 0.1.96-beta.21
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 +106 -35
- package/bin/tycono.ts +23 -1
- package/package.json +1 -1
- package/src/api/src/routes/active-sessions.ts +3 -0
- package/src/api/src/routes/execute.ts +6 -12
- package/src/api/src/services/supervisor-heartbeat.ts +19 -0
- package/src/tui/api.ts +40 -7
- package/src/tui/app.tsx +369 -173
- package/src/tui/components/CommandMode.tsx +277 -0
- package/src/tui/components/OrgTree.tsx +4 -17
- package/src/tui/components/PanelMode.tsx +265 -0
- package/src/tui/components/SetupWizard.tsx +10 -3
- package/src/tui/components/StatusBar.tsx +44 -25
- package/src/tui/components/{StreamPanel.tsx → StreamView.tsx} +53 -73
- package/src/tui/hooks/useApi.ts +27 -6
- package/src/tui/hooks/useCommand.ts +162 -0
- package/src/tui/hooks/useSSE.ts +68 -24
- package/src/tui/store.ts +12 -0
- package/src/tui/components/CommandInput.tsx +0 -32
- package/src/tui/components/HelpOverlay.tsx +0 -51
- package/src/tui/components/SessionList.tsx +0 -74
- package/src/tui/components/WaveDialog.tsx +0 -56
- package/src/tui/hooks/useKeyboard.ts +0 -62
package/src/tui/app.tsx
CHANGED
|
@@ -1,46 +1,154 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TUI App —
|
|
2
|
+
* TUI App v2 — Multi-Wave Hybrid Mode (Command + Panel)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* ├──────────────┴──────────────────────────┤
|
|
13
|
-
* │ CommandInput │
|
|
14
|
-
* └─────────────────────────────────────────┘
|
|
4
|
+
* Wave = Claude Code session. Persistent, resumable.
|
|
5
|
+
* Multiple waves can be open; user switches with /focus.
|
|
6
|
+
*
|
|
7
|
+
* Two modes:
|
|
8
|
+
* Command Mode (default) — stream summary + command input (> prompt)
|
|
9
|
+
* Panel Mode (Tab) — Org Tree left + Role stream right
|
|
10
|
+
*
|
|
11
|
+
* Tab toggles between modes, Esc returns to Command Mode.
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
|
-
import React, { useState, useCallback, useMemo } from 'react';
|
|
18
|
-
import { Box, Text, useApp } from 'ink';
|
|
14
|
+
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
15
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
19
16
|
import { StatusBar } from './components/StatusBar';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { StreamPanel } from './components/StreamPanel';
|
|
23
|
-
import { CommandInput } from './components/CommandInput';
|
|
24
|
-
import { WaveDialog } from './components/WaveDialog';
|
|
25
|
-
import { HelpOverlay } from './components/HelpOverlay';
|
|
17
|
+
import { CommandMode, type StreamLine } from './components/CommandMode';
|
|
18
|
+
import { PanelMode } from './components/PanelMode';
|
|
26
19
|
import { SetupWizard } from './components/SetupWizard';
|
|
27
20
|
import { useApi } from './hooks/useApi';
|
|
28
21
|
import { useSSE } from './hooks/useSSE';
|
|
29
|
-
import {
|
|
30
|
-
import { buildOrgTree } from './store';
|
|
22
|
+
import { useCommand, type WaveInfo } from './hooks/useCommand';
|
|
31
23
|
import { dispatchWave } from './api';
|
|
24
|
+
import type { ActiveSessionInfo } from './api';
|
|
25
|
+
import { buildOrgTree, flattenOrgRoleIds } from './store';
|
|
32
26
|
|
|
33
|
-
type
|
|
34
|
-
type Dialog = 'none' | 'wave' | 'help';
|
|
27
|
+
type Mode = 'command' | 'panel';
|
|
35
28
|
type View = 'loading' | 'setup' | 'dashboard';
|
|
36
29
|
|
|
37
|
-
|
|
30
|
+
let sysLineId = 100000;
|
|
31
|
+
|
|
32
|
+
/** Format agent tree for /agents command */
|
|
33
|
+
function formatAgentsTree(
|
|
34
|
+
waves: WaveInfo[],
|
|
35
|
+
activeSessions: ActiveSessionInfo[],
|
|
36
|
+
focusedWaveId: string | null,
|
|
37
|
+
): StreamLine[] {
|
|
38
|
+
const lines: StreamLine[] = [];
|
|
39
|
+
|
|
40
|
+
if (waves.length === 0 && activeSessions.length === 0) {
|
|
41
|
+
lines.push({ id: ++sysLineId, text: 'No active agents.', color: 'gray' });
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Group sessions by waveId
|
|
46
|
+
const sessionsByWave = new Map<string, ActiveSessionInfo[]>();
|
|
47
|
+
const unlinked: ActiveSessionInfo[] = [];
|
|
48
|
+
for (const s of activeSessions) {
|
|
49
|
+
if (s.waveId) {
|
|
50
|
+
if (!sessionsByWave.has(s.waveId)) sessionsByWave.set(s.waveId, []);
|
|
51
|
+
sessionsByWave.get(s.waveId)!.push(s);
|
|
52
|
+
} else {
|
|
53
|
+
unlinked.push(s);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Display each wave
|
|
58
|
+
for (let i = 0; i < waves.length; i++) {
|
|
59
|
+
const w = waves[i];
|
|
60
|
+
const isFocused = w.waveId === focusedWaveId;
|
|
61
|
+
const marker = isFocused ? '*' : ' ';
|
|
62
|
+
const label = w.directive ? w.directive.slice(0, 50) : '(idle)';
|
|
63
|
+
lines.push({
|
|
64
|
+
id: ++sysLineId,
|
|
65
|
+
text: `${marker}Wave ${i + 1}: "${label}"`,
|
|
66
|
+
color: isFocused ? 'green' : 'cyan',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const waveSessions = sessionsByWave.get(w.waveId) ?? [];
|
|
70
|
+
if (waveSessions.length === 0) {
|
|
71
|
+
lines.push({ id: ++sysLineId, text: ' (no agents)', color: 'gray' });
|
|
72
|
+
}
|
|
73
|
+
for (const s of waveSessions) {
|
|
74
|
+
const statusIcon = s.status === 'active' ? '\u25CF' : s.status === 'dead' ? '\u25CF' : '\u25CB';
|
|
75
|
+
const statusColor = s.status === 'active' ? 'green' : s.status === 'dead' ? 'red' : 'gray';
|
|
76
|
+
const portInfo = s.ports.api ? `API:${s.ports.api} Vite:${s.ports.vite}` : '(no ports)';
|
|
77
|
+
const worktree = s.worktreePath ? `\u{1F33F} ${s.worktreePath.split('/').pop()}` : '';
|
|
78
|
+
lines.push({
|
|
79
|
+
id: ++sysLineId,
|
|
80
|
+
text: ` ${statusIcon} ${s.roleId.padEnd(14)} ${portInfo}${worktree ? ' ' + worktree : ''}`,
|
|
81
|
+
color: statusColor,
|
|
82
|
+
});
|
|
83
|
+
if (s.task) {
|
|
84
|
+
lines.push({
|
|
85
|
+
id: ++sysLineId,
|
|
86
|
+
text: ` ${s.task.slice(0, 60)}`,
|
|
87
|
+
color: 'gray',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Unlinked sessions (not associated with any wave)
|
|
94
|
+
if (unlinked.length > 0) {
|
|
95
|
+
lines.push({ id: ++sysLineId, text: '', color: 'white' });
|
|
96
|
+
lines.push({ id: ++sysLineId, text: 'Unlinked sessions:', color: 'yellow' });
|
|
97
|
+
for (const s of unlinked) {
|
|
98
|
+
const portInfo = s.ports.api ? `API:${s.ports.api} Vite:${s.ports.vite}` : '(no ports)';
|
|
99
|
+
lines.push({
|
|
100
|
+
id: ++sysLineId,
|
|
101
|
+
text: ` ${s.roleId.padEnd(14)} ${portInfo} ${s.task?.slice(0, 40) ?? ''}`,
|
|
102
|
+
color: 'gray',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Format port allocations for /ports command */
|
|
111
|
+
function formatPortsList(activeSessions: ActiveSessionInfo[], portSummary: { active: number; totalPorts: number }): StreamLine[] {
|
|
112
|
+
const lines: StreamLine[] = [];
|
|
113
|
+
|
|
114
|
+
if (activeSessions.length === 0) {
|
|
115
|
+
lines.push({ id: ++sysLineId, text: 'No port allocations.', color: 'gray' });
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push({
|
|
120
|
+
id: ++sysLineId,
|
|
121
|
+
text: `Port Allocations (${portSummary.active} active, ${portSummary.totalPorts} ports):`,
|
|
122
|
+
color: 'cyan',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
for (const s of activeSessions) {
|
|
126
|
+
const alive = s.alive === false ? ' DEAD' : s.pid ? ` PID:${s.pid}` : '';
|
|
127
|
+
const waveLabel = s.waveId ? ` (${s.waveId.replace('wave-', 'W')})` : '';
|
|
128
|
+
lines.push({
|
|
129
|
+
id: ++sysLineId,
|
|
130
|
+
text: ` :${s.ports.api}/:${s.ports.vite} \u2192 ${s.roleId}${waveLabel}${alive}`,
|
|
131
|
+
color: s.alive === false ? 'red' : 'white',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Available range hint
|
|
136
|
+
const usedApi = activeSessions.map(s => s.ports.api).filter(Boolean);
|
|
137
|
+
const maxApi = usedApi.length > 0 ? Math.max(...usedApi) + 1 : 3001;
|
|
138
|
+
lines.push({
|
|
139
|
+
id: ++sysLineId,
|
|
140
|
+
text: ` Available: :${maxApi}+ API, :${5173 + activeSessions.length}+ Vite`,
|
|
141
|
+
color: 'gray',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
38
146
|
|
|
39
147
|
export const App: React.FC = () => {
|
|
40
148
|
const { exit } = useApp();
|
|
41
149
|
const api = useApi();
|
|
42
150
|
|
|
43
|
-
//
|
|
151
|
+
// View state: loading -> setup (no company) -> dashboard
|
|
44
152
|
const [view, setView] = useState<View>('loading');
|
|
45
153
|
|
|
46
154
|
React.useEffect(() => {
|
|
@@ -55,84 +163,218 @@ export const App: React.FC = () => {
|
|
|
55
163
|
setView('dashboard');
|
|
56
164
|
}, [api]);
|
|
57
165
|
|
|
58
|
-
|
|
59
|
-
const [
|
|
166
|
+
// Mode state
|
|
167
|
+
const [mode, setMode] = useState<Mode>('command');
|
|
168
|
+
|
|
169
|
+
// Multi-Wave state
|
|
170
|
+
const [waves, setWaves] = useState<WaveInfo[]>([]);
|
|
171
|
+
const [focusedWaveId, setFocusedWaveId] = useState<string | null>(null);
|
|
172
|
+
const autoWaveCreated = useRef(false);
|
|
173
|
+
|
|
174
|
+
// Panel mode state
|
|
60
175
|
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
|
61
|
-
const [
|
|
62
|
-
|
|
63
|
-
|
|
176
|
+
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
|
177
|
+
|
|
178
|
+
// System messages (command feedback displayed in stream area)
|
|
179
|
+
const [systemMessages, setSystemMessages] = useState<StreamLine[]>([]);
|
|
180
|
+
|
|
181
|
+
// Terminal full height with resize tracking (minus 1 for wide-char overflow safety)
|
|
182
|
+
const [termHeight, setTermHeight] = useState((process.stdout.rows || 30) - 1);
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const onResize = () => {
|
|
186
|
+
setTermHeight((process.stdout.rows || 30) - 1);
|
|
187
|
+
};
|
|
188
|
+
process.stdout.on('resize', onResize);
|
|
189
|
+
return () => {
|
|
190
|
+
process.stdout.off('resize', onResize);
|
|
191
|
+
};
|
|
192
|
+
}, []);
|
|
64
193
|
|
|
65
|
-
|
|
66
|
-
|
|
194
|
+
const addSystemMessage = useCallback((text: string, color: string = 'yellow') => {
|
|
195
|
+
setSystemMessages(prev => {
|
|
196
|
+
const next = [...prev, { id: ++sysLineId, text, color }];
|
|
197
|
+
return next.length > 50 ? next.slice(-50) : next;
|
|
198
|
+
});
|
|
199
|
+
}, []);
|
|
67
200
|
|
|
68
|
-
const
|
|
201
|
+
const addSystemLines = useCallback((lines: StreamLine[]) => {
|
|
202
|
+
setSystemMessages(prev => {
|
|
203
|
+
const next = [...prev, ...lines];
|
|
204
|
+
return next.length > 80 ? next.slice(-80) : next;
|
|
205
|
+
});
|
|
206
|
+
}, []);
|
|
69
207
|
|
|
70
|
-
//
|
|
208
|
+
// Auto-wave: on dashboard entry, create an empty wave or attach to existing
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (view !== 'dashboard' || autoWaveCreated.current) return;
|
|
211
|
+
|
|
212
|
+
if (api.activeWaves.length > 0) {
|
|
213
|
+
// Attach to existing waves from API
|
|
214
|
+
const apiWaves: WaveInfo[] = api.activeWaves.map(w => ({
|
|
215
|
+
waveId: w.waveId,
|
|
216
|
+
directive: w.directive ?? '',
|
|
217
|
+
startedAt: w.startedAt ?? Date.now(),
|
|
218
|
+
}));
|
|
219
|
+
setWaves(apiWaves);
|
|
220
|
+
setFocusedWaveId(apiWaves[apiWaves.length - 1].waveId);
|
|
221
|
+
autoWaveCreated.current = true;
|
|
222
|
+
} else if (api.loaded) {
|
|
223
|
+
// Create a new empty wave
|
|
224
|
+
autoWaveCreated.current = true;
|
|
225
|
+
dispatchWave().then(result => {
|
|
226
|
+
const newWave: WaveInfo = {
|
|
227
|
+
waveId: result.waveId,
|
|
228
|
+
directive: '',
|
|
229
|
+
startedAt: Date.now(),
|
|
230
|
+
};
|
|
231
|
+
setWaves([newWave]);
|
|
232
|
+
setFocusedWaveId(result.waveId);
|
|
233
|
+
}).catch(() => {
|
|
234
|
+
// If empty wave creation fails, still proceed — user can /new
|
|
235
|
+
autoWaveCreated.current = true;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}, [view, api.activeWaves, api.loaded]);
|
|
239
|
+
|
|
240
|
+
// SSE subscription to focused wave
|
|
241
|
+
const sse = useSSE(focusedWaveId);
|
|
242
|
+
|
|
243
|
+
// Build org tree — flatRoleIds follows visual top-to-bottom order
|
|
71
244
|
const roles = api.company?.roles ?? [];
|
|
72
|
-
const flatRoleIds = useMemo(() => roles.map(r => r.id), [roles]);
|
|
73
245
|
const statuses = api.execStatus?.statuses ?? {};
|
|
74
246
|
const orgTree = useMemo(() => buildOrgTree(roles, statuses), [roles, statuses]);
|
|
247
|
+
const flatRoleIds = useMemo(() => flattenOrgRoleIds(orgTree), [orgTree]);
|
|
75
248
|
|
|
76
|
-
//
|
|
77
|
-
const activeCount = Object.values(statuses).filter(
|
|
249
|
+
// Active count
|
|
250
|
+
const activeCount = Object.values(statuses).filter(
|
|
251
|
+
s => s === 'working' || s === 'streaming'
|
|
252
|
+
).length;
|
|
78
253
|
|
|
79
|
-
//
|
|
254
|
+
// Derived wave status
|
|
80
255
|
const derivedWaveStatus = useMemo(() => {
|
|
81
256
|
if (sse.streamStatus === 'streaming') return 'running' as const;
|
|
82
257
|
if (sse.streamStatus === 'done') return 'done' as const;
|
|
83
|
-
if (
|
|
84
|
-
return
|
|
85
|
-
}, [sse.streamStatus,
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
258
|
+
if (activeCount > 0) return 'running' as const;
|
|
259
|
+
return 'idle' as const;
|
|
260
|
+
}, [sse.streamStatus, activeCount]);
|
|
261
|
+
|
|
262
|
+
// Focused wave index (1-based)
|
|
263
|
+
const focusedWaveIndex = useMemo(() => {
|
|
264
|
+
if (!focusedWaveId) return 0;
|
|
265
|
+
return waves.findIndex(w => w.waveId === focusedWaveId) + 1;
|
|
266
|
+
}, [focusedWaveId, waves]);
|
|
267
|
+
|
|
268
|
+
// Command handler
|
|
269
|
+
const { execute } = useCommand({
|
|
270
|
+
focusedWaveId,
|
|
271
|
+
waves,
|
|
272
|
+
onWaveCreated: (newWaveId, directive) => {
|
|
273
|
+
const newWave: WaveInfo = {
|
|
274
|
+
waveId: newWaveId,
|
|
275
|
+
directive,
|
|
276
|
+
startedAt: Date.now(),
|
|
277
|
+
};
|
|
278
|
+
setWaves(prev => [...prev, newWave]);
|
|
279
|
+
setFocusedWaveId(newWaveId);
|
|
94
280
|
sse.clearEvents();
|
|
95
281
|
api.refresh();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
282
|
+
},
|
|
283
|
+
onFocusWave: (waveId) => {
|
|
284
|
+
setFocusedWaveId(waveId);
|
|
285
|
+
sse.clearEvents();
|
|
286
|
+
},
|
|
287
|
+
onQuit: () => exit(),
|
|
288
|
+
onShowPanel: () => setMode('panel'),
|
|
289
|
+
});
|
|
101
290
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
291
|
+
// Handle command submission from CommandMode
|
|
292
|
+
const handleCommandSubmit = useCallback(async (input: string) => {
|
|
293
|
+
addSystemMessage(`> ${input}`, 'white');
|
|
104
294
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
295
|
+
const result = await execute(input);
|
|
296
|
+
|
|
297
|
+
switch (result.type) {
|
|
298
|
+
case 'wave_started':
|
|
299
|
+
break;
|
|
300
|
+
case 'directive_sent':
|
|
301
|
+
break;
|
|
302
|
+
case 'focus_changed':
|
|
303
|
+
addSystemMessage(`\u2192 ${result.message}`, 'cyan');
|
|
304
|
+
break;
|
|
305
|
+
case 'waves_list': {
|
|
306
|
+
if (waves.length === 0) {
|
|
307
|
+
addSystemMessage('No waves.', 'gray');
|
|
308
|
+
} else {
|
|
309
|
+
addSystemMessage('Waves:', 'cyan');
|
|
310
|
+
waves.forEach((w, i) => {
|
|
311
|
+
const isFocused = w.waveId === focusedWaveId;
|
|
312
|
+
const prefix = isFocused ? '*' : ' ';
|
|
313
|
+
const label = w.directive ? w.directive.slice(0, 60) : '(idle)';
|
|
314
|
+
addSystemMessage(`${prefix}${i + 1}. Wave ${i + 1} \u2014 ${label}`, isFocused ? 'green' : 'white');
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
118
318
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
} else if (activePanel === 'sessions') {
|
|
124
|
-
setSelectedSessionIndex(Math.min(Math.max(0, api.sessions.length - 1), selectedSessionIndex + 1));
|
|
319
|
+
case 'agents': {
|
|
320
|
+
const lines = formatAgentsTree(waves, api.activeSessions, focusedWaveId);
|
|
321
|
+
addSystemLines(lines);
|
|
322
|
+
break;
|
|
125
323
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
onEscape: () => {
|
|
131
|
-
if (dialog !== 'none') {
|
|
132
|
-
setDialog('none');
|
|
324
|
+
case 'ports': {
|
|
325
|
+
const lines = formatPortsList(api.activeSessions, api.portSummary);
|
|
326
|
+
addSystemLines(lines);
|
|
327
|
+
break;
|
|
133
328
|
}
|
|
134
|
-
|
|
135
|
-
|
|
329
|
+
case 'error':
|
|
330
|
+
addSystemMessage(result.message, 'red');
|
|
331
|
+
break;
|
|
332
|
+
case 'help':
|
|
333
|
+
addSystemMessage('Type naturally to talk to your AI team.', 'cyan');
|
|
334
|
+
addSystemMessage('Commands:', 'cyan');
|
|
335
|
+
addSystemMessage(' /new [text] Create new wave', 'white');
|
|
336
|
+
addSystemMessage(' /waves List all waves', 'white');
|
|
337
|
+
addSystemMessage(' /focus <n> Switch to wave n', 'white');
|
|
338
|
+
addSystemMessage(' /agents Agent tree + resources', 'white');
|
|
339
|
+
addSystemMessage(' /ports Port allocations', 'white');
|
|
340
|
+
addSystemMessage(' /status Show current status', 'white');
|
|
341
|
+
addSystemMessage(' /assign <role> <task> Assign task to role', 'white');
|
|
342
|
+
addSystemMessage(' /roles Org tree (Panel Mode)', 'white');
|
|
343
|
+
addSystemMessage(' /help Show this help', 'white');
|
|
344
|
+
addSystemMessage(' /quit Exit TUI', 'white');
|
|
345
|
+
addSystemMessage('Keys: [Tab] panel [Esc] back [Ctrl+C] quit', 'gray');
|
|
346
|
+
break;
|
|
347
|
+
case 'info':
|
|
348
|
+
if (result.message === '__status__') {
|
|
349
|
+
const wLabel = focusedWaveId
|
|
350
|
+
? `Wave ${focusedWaveIndex}: ${derivedWaveStatus}`
|
|
351
|
+
: 'No active wave';
|
|
352
|
+
addSystemMessage(wLabel, derivedWaveStatus === 'running' ? 'green' : 'gray');
|
|
353
|
+
addSystemMessage(`Sessions: ${api.sessions.length} Active: ${activeCount} Waves: ${waves.length} Ports: ${api.portSummary.totalPorts}`, 'white');
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
case 'panel':
|
|
357
|
+
break;
|
|
358
|
+
case 'quit':
|
|
359
|
+
break;
|
|
360
|
+
default:
|
|
361
|
+
if (result.message) {
|
|
362
|
+
addSystemMessage(result.message, 'green');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}, [execute, addSystemMessage, addSystemLines, focusedWaveId, focusedWaveIndex, derivedWaveStatus, api.sessions.length, activeCount, waves, api.activeSessions, api.portSummary]);
|
|
366
|
+
|
|
367
|
+
// Global key handler: Tab to toggle mode, Ctrl+C handling
|
|
368
|
+
useInput((input, key) => {
|
|
369
|
+
if (mode === 'command' && key.tab) {
|
|
370
|
+
setMode('panel');
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// Ctrl+C in command mode: exit
|
|
374
|
+
if (key.ctrl && input === 'c') {
|
|
375
|
+
exit();
|
|
376
|
+
}
|
|
377
|
+
}, { isActive: mode === 'command' });
|
|
136
378
|
|
|
137
379
|
// Loading state
|
|
138
380
|
if (view === 'loading') {
|
|
@@ -144,7 +386,7 @@ export const App: React.FC = () => {
|
|
|
144
386
|
);
|
|
145
387
|
}
|
|
146
388
|
|
|
147
|
-
// Setup wizard
|
|
389
|
+
// Setup wizard
|
|
148
390
|
if (view === 'setup') {
|
|
149
391
|
return (
|
|
150
392
|
<Box flexDirection="column" paddingX={1}>
|
|
@@ -160,107 +402,61 @@ export const App: React.FC = () => {
|
|
|
160
402
|
<Text color="cyan" bold>TYCONO TUI</Text>
|
|
161
403
|
<Text color="red">API Error: {api.error}</Text>
|
|
162
404
|
<Text color="gray">Make sure the API server is running on the configured port.</Text>
|
|
163
|
-
<Text color="gray" dimColor>Press
|
|
164
|
-
</Box>
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Help overlay
|
|
169
|
-
if (dialog === 'help') {
|
|
170
|
-
return (
|
|
171
|
-
<Box flexDirection="column">
|
|
172
|
-
<StatusBar
|
|
173
|
-
companyName={api.company?.name ?? 'Loading...'}
|
|
174
|
-
waveId={effectiveWaveId}
|
|
175
|
-
waveStatus={derivedWaveStatus}
|
|
176
|
-
activeCount={activeCount}
|
|
177
|
-
totalCost={0}
|
|
178
|
-
/>
|
|
179
|
-
<HelpOverlay onClose={() => setDialog('none')} />
|
|
405
|
+
<Text color="gray" dimColor>Press Ctrl+C to quit</Text>
|
|
180
406
|
</Box>
|
|
181
407
|
);
|
|
182
408
|
}
|
|
183
409
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
<Box flexDirection="column">
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
410
|
+
return (
|
|
411
|
+
<Box flexDirection="column" height={termHeight}>
|
|
412
|
+
{/* Mode content — fill remaining height */}
|
|
413
|
+
<Box flexGrow={1} flexDirection="column">
|
|
414
|
+
{mode === 'command' ? (
|
|
415
|
+
<CommandMode
|
|
416
|
+
events={sse.events}
|
|
417
|
+
allRoleIds={flatRoleIds}
|
|
418
|
+
systemMessages={systemMessages}
|
|
419
|
+
onSubmit={handleCommandSubmit}
|
|
194
420
|
/>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
421
|
+
) : (
|
|
422
|
+
<PanelMode
|
|
423
|
+
tree={orgTree}
|
|
424
|
+
flatRoles={flatRoleIds}
|
|
425
|
+
events={sse.events}
|
|
426
|
+
selectedRoleIndex={selectedRoleIndex}
|
|
427
|
+
selectedRoleId={selectedRoleId}
|
|
428
|
+
streamStatus={sse.streamStatus}
|
|
429
|
+
waveId={focusedWaveId}
|
|
430
|
+
activeSessions={api.activeSessions}
|
|
431
|
+
waves={waves}
|
|
432
|
+
focusedWaveId={focusedWaveId}
|
|
433
|
+
portSummary={api.portSummary}
|
|
434
|
+
onMove={(dir) => {
|
|
435
|
+
if (dir === 'up') {
|
|
436
|
+
setSelectedRoleIndex(Math.max(0, selectedRoleIndex - 1));
|
|
437
|
+
} else {
|
|
438
|
+
setSelectedRoleIndex(Math.min(flatRoleIds.length - 1, selectedRoleIndex + 1));
|
|
439
|
+
}
|
|
440
|
+
}}
|
|
441
|
+
onSelect={() => {
|
|
442
|
+
const roleId = flatRoleIds[selectedRoleIndex] ?? null;
|
|
443
|
+
setSelectedRoleId(roleId === selectedRoleId ? null : roleId);
|
|
444
|
+
}}
|
|
445
|
+
onEscape={() => setMode('command')}
|
|
198
446
|
/>
|
|
447
|
+
)}
|
|
199
448
|
</Box>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
449
|
|
|
203
|
-
|
|
204
|
-
<Box flexDirection="column">
|
|
205
|
-
{/* Status Bar */}
|
|
450
|
+
{/* Status Bar — bottom (Claude Code style) */}
|
|
206
451
|
<StatusBar
|
|
207
452
|
companyName={api.company?.name ?? 'Loading...'}
|
|
208
|
-
|
|
453
|
+
waveIndex={focusedWaveIndex}
|
|
454
|
+
waveCount={waves.length}
|
|
209
455
|
waveStatus={derivedWaveStatus}
|
|
210
456
|
activeCount={activeCount}
|
|
457
|
+
portCount={api.portSummary.totalPorts}
|
|
211
458
|
totalCost={0}
|
|
212
459
|
/>
|
|
213
|
-
|
|
214
|
-
{/* Separator */}
|
|
215
|
-
<Box width="100%">
|
|
216
|
-
<Text color="gray">{'\u2500'.repeat(70)}</Text>
|
|
217
|
-
</Box>
|
|
218
|
-
|
|
219
|
-
{/* Main content: left (org + sessions) | right (stream) */}
|
|
220
|
-
<Box flexGrow={1}>
|
|
221
|
-
{/* Left column */}
|
|
222
|
-
<Box flexDirection="column" width={28}>
|
|
223
|
-
<OrgTree
|
|
224
|
-
tree={orgTree}
|
|
225
|
-
focused={activePanel === 'org'}
|
|
226
|
-
selectedIndex={selectedRoleIndex}
|
|
227
|
-
flatRoles={flatRoleIds}
|
|
228
|
-
/>
|
|
229
|
-
<Box marginTop={1}>
|
|
230
|
-
<SessionList
|
|
231
|
-
sessions={api.sessions}
|
|
232
|
-
focused={activePanel === 'sessions'}
|
|
233
|
-
selectedIndex={selectedSessionIndex}
|
|
234
|
-
/>
|
|
235
|
-
</Box>
|
|
236
|
-
</Box>
|
|
237
|
-
|
|
238
|
-
{/* Vertical separator */}
|
|
239
|
-
<Box flexDirection="column" marginX={0}>
|
|
240
|
-
<Text color="gray">{'\u2502\n'.repeat(15)}</Text>
|
|
241
|
-
</Box>
|
|
242
|
-
|
|
243
|
-
{/* Right column: Stream */}
|
|
244
|
-
<StreamPanel
|
|
245
|
-
events={sse.events}
|
|
246
|
-
allRoleIds={flatRoleIds}
|
|
247
|
-
focused={activePanel === 'stream'}
|
|
248
|
-
streamStatus={sse.streamStatus}
|
|
249
|
-
waveId={effectiveWaveId}
|
|
250
|
-
/>
|
|
251
|
-
</Box>
|
|
252
|
-
|
|
253
|
-
{/* Separator */}
|
|
254
|
-
<Box width="100%">
|
|
255
|
-
<Text color="gray">{'\u2500'.repeat(70)}</Text>
|
|
256
|
-
</Box>
|
|
257
|
-
|
|
258
|
-
{/* Command Input */}
|
|
259
|
-
<CommandInput
|
|
260
|
-
focused={activePanel === 'command'}
|
|
261
|
-
waveStatus={derivedWaveStatus}
|
|
262
|
-
dialog={dialog}
|
|
263
|
-
/>
|
|
264
460
|
</Box>
|
|
265
461
|
);
|
|
266
462
|
};
|