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/src/tui/app.tsx CHANGED
@@ -1,46 +1,154 @@
1
1
  /**
2
- * TUI App — main layout with 4 panels
2
+ * TUI App v2 Multi-Wave Hybrid Mode (Command + Panel)
3
3
  *
4
- * Layout:
5
- * ┌─────────────────────────────────────────┐
6
- * │ StatusBar │
7
- * ├──────────────┬──────────────────────────┤
8
- * OrgTree │ StreamPanel │
9
- * │ │ │
10
- * ├──────────────┤ │
11
- * SessionList │ │
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 { 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';
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 { useKeyboard } from './hooks/useKeyboard';
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 Panel = 'org' | 'sessions' | 'stream' | 'command';
34
- type Dialog = 'none' | 'wave' | 'help';
27
+ type Mode = 'command' | 'panel';
35
28
  type View = 'loading' | 'setup' | 'dashboard';
36
29
 
37
- const PANELS: Panel[] = ['org', 'sessions', 'stream', 'command'];
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
- // Determine view: loading setup (no company) dashboard
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
- const [activePanel, setActivePanel] = useState<Panel>('org');
59
- const [dialog, setDialog] = useState<Dialog>('none');
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 [selectedSessionIndex, setSelectedSessionIndex] = useState(0);
62
- const [waveId, setWaveId] = useState<string | null>(null);
63
- const [waveStatus, setWaveStatus] = useState<'idle' | 'running' | 'done'>('idle');
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
- // Derive active wave from API if we don't have one
66
- const effectiveWaveId = waveId ?? api.activeWaveId;
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 sse = useSSE(effectiveWaveId);
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
- // Build org tree
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
- // Count active
77
- const activeCount = Object.values(statuses).filter(s => s === 'working' || s === 'streaming').length;
249
+ // Active count
250
+ const activeCount = Object.values(statuses).filter(
251
+ s => s === 'working' || s === 'streaming'
252
+ ).length;
78
253
 
79
- // Determine wave status from SSE
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 (waveStatus === 'running' && activeCount > 0) return 'running' as const;
84
- return waveStatus;
85
- }, [sse.streamStatus, waveStatus, activeCount]);
86
-
87
- // Handle wave dispatch
88
- const handleWaveSubmit = useCallback(async (directive: string) => {
89
- setDialog('none');
90
- try {
91
- const result = await dispatchWave(directive);
92
- setWaveId(result.waveId);
93
- setWaveStatus('running');
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
- } catch (err) {
97
- // Show error briefly
98
- console.error('Wave dispatch failed:', err);
99
- }
100
- }, [sse, api]);
282
+ },
283
+ onFocusWave: (waveId) => {
284
+ setFocusedWaveId(waveId);
285
+ sse.clearEvents();
286
+ },
287
+ onQuit: () => exit(),
288
+ onShowPanel: () => setMode('panel'),
289
+ });
101
290
 
102
- // Keyboard actions disabled when dialog is open
103
- const keyboardEnabled = dialog === 'none';
291
+ // Handle command submission from CommandMode
292
+ const handleCommandSubmit = useCallback(async (input: string) => {
293
+ addSystemMessage(`> ${input}`, 'white');
104
294
 
105
- useKeyboard({
106
- onWave: () => setDialog('wave'),
107
- onQuit: () => exit(),
108
- onHelp: () => setDialog(dialog === 'help' ? 'none' : 'help'),
109
- onTab: () => {
110
- const idx = PANELS.indexOf(activePanel);
111
- setActivePanel(PANELS[(idx + 1) % PANELS.length]);
112
- },
113
- onUp: () => {
114
- if (activePanel === 'org') {
115
- setSelectedRoleIndex(Math.max(0, selectedRoleIndex - 1));
116
- } else if (activePanel === 'sessions') {
117
- setSelectedSessionIndex(Math.max(0, selectedSessionIndex - 1));
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
- onDown: () => {
121
- if (activePanel === 'org') {
122
- setSelectedRoleIndex(Math.min(flatRoleIds.length - 1, selectedRoleIndex + 1));
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
- onEnter: () => {
128
- // Future: select role/session to show in stream
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
- }, keyboardEnabled);
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 — no company found
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 q to quit</Text>
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
- // Wave dialog
185
- if (dialog === 'wave') {
186
- return (
187
- <Box flexDirection="column">
188
- <StatusBar
189
- companyName={api.company?.name ?? 'Loading...'}
190
- waveId={effectiveWaveId}
191
- waveStatus={derivedWaveStatus}
192
- activeCount={activeCount}
193
- totalCost={0}
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
- <WaveDialog
196
- onSubmit={handleWaveSubmit}
197
- onCancel={() => setDialog('none')}
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
- return (
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
- waveId={effectiveWaveId}
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
  };