tycono 0.1.96-beta.35 → 0.1.96-beta.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.96-beta.35",
3
+ "version": "0.1.96-beta.37",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/tui/app.tsx CHANGED
@@ -395,6 +395,64 @@ export const App: React.FC = () => {
395
395
  api.refresh();
396
396
  break;
397
397
  }
398
+ case 'docs': {
399
+ // Extract written/edited files from SSE events in current wave
400
+ const writtenFiles = new Set<string>();
401
+ for (const event of sse.events) {
402
+ if (event.type === 'tool:start') {
403
+ const name = (event.data.name as string) ?? '';
404
+ const input = event.data.input as Record<string, unknown> | undefined;
405
+ if (['Write', 'Edit', 'NotebookEdit'].includes(name) && input?.file_path) {
406
+ writtenFiles.add(String(input.file_path));
407
+ }
408
+ }
409
+ }
410
+ if (writtenFiles.size === 0) {
411
+ addSystemMessage('No files created/modified in this wave.', 'gray');
412
+ } else {
413
+ addSystemMessage(`Files in this wave (${writtenFiles.size}):`, 'cyan');
414
+ for (const f of writtenFiles) {
415
+ const short = f.split('/').slice(-3).join('/');
416
+ addSystemMessage(` ${short}`, 'white');
417
+ }
418
+ addSystemMessage(' /read <path> to preview | /open <path> to edit', 'gray');
419
+ }
420
+ break;
421
+ }
422
+ case 'read_file': {
423
+ const filePath = result.message;
424
+ try {
425
+ const content = await import('node:fs').then(fs =>
426
+ fs.readFileSync(filePath, 'utf-8')
427
+ );
428
+ const lines = content.split('\n');
429
+ const preview = lines.slice(0, 30);
430
+ addSystemMessage(`\u2500\u2500 ${filePath.split('/').slice(-2).join('/')} \u2500\u2500`, 'cyan');
431
+ for (const line of preview) {
432
+ addSystemMessage(line, 'white');
433
+ }
434
+ if (lines.length > 30) {
435
+ addSystemMessage(` ... +${lines.length - 30} more lines (/open to see full)`, 'gray');
436
+ }
437
+ addSystemMessage('\u2500'.repeat(40), 'gray');
438
+ } catch (err) {
439
+ addSystemMessage(`Cannot read: ${err instanceof Error ? err.message : 'unknown'}`, 'red');
440
+ }
441
+ break;
442
+ }
443
+ case 'open_file': {
444
+ const filePath = result.message;
445
+ const editor = process.env.EDITOR || process.env.VISUAL || 'less';
446
+ try {
447
+ const { execSync } = await import('node:child_process');
448
+ execSync(`${editor} "${filePath}"`, { stdio: 'inherit' });
449
+ addSystemMessage(`Opened: ${filePath}`, 'green');
450
+ } catch {
451
+ // Fallback to /read
452
+ addSystemMessage(`Cannot open with ${editor}. Use /read instead.`, 'yellow');
453
+ }
454
+ break;
455
+ }
398
456
  case 'error':
399
457
  addSystemMessage(result.message, 'red');
400
458
  break;
@@ -404,11 +462,13 @@ export const App: React.FC = () => {
404
462
  addSystemMessage(' /new [text] Create new wave', 'white');
405
463
  addSystemMessage(' /waves List all waves', 'white');
406
464
  addSystemMessage(' /focus <n> Switch to wave n', 'white');
465
+ addSystemMessage(' /docs Files created in this wave', 'white');
466
+ addSystemMessage(' /read <path> Preview file content', 'white');
467
+ addSystemMessage(' /open <path> Open in $EDITOR', 'white');
407
468
  addSystemMessage(' /agents Wave \u2192 Role \u2192 Session tree', 'white');
408
469
  addSystemMessage(' /sessions Sessions + ports (kill/cleanup)', 'white');
409
470
  addSystemMessage(' /kill <id> Kill a session', 'white');
410
471
  addSystemMessage(' /cleanup Remove dead sessions', 'white');
411
- addSystemMessage(' /status Current status', 'white');
412
472
  addSystemMessage(' /help This help', 'white');
413
473
  addSystemMessage(' /quit Exit', 'white');
414
474
  addSystemMessage('Keys: [Tab] team panel [1-9] wave [Esc] back [Ctrl+C] quit', 'gray');
@@ -1,26 +1,29 @@
1
1
  /**
2
- * PanelMode — Wave-scoped team view
2
+ * PanelMode — Wave-scoped team view with right-panel tabs
3
3
  *
4
- * Shows focused wave's team state:
5
- * Left: Wave title + Org Tree (wave-scoped status) + Wave tabs
6
- * Right: Selected role's resources + stream
4
+ * Left: Wave title + Org Tree (wave-scoped) + Wave tabs
5
+ * Right: [Stream] [Docs] [Info] tab switching with h/l
7
6
  *
8
7
  * Navigation:
9
- * j/k — move in Org Tree (auto-selects)
8
+ * j/k — move in Org Tree (auto-selects) or scroll in Docs
9
+ * h/l — switch right panel tab
10
10
  * 1-9 — switch wave focus
11
- * Enter — toggle filtered/all stream
11
+ * Enter — Stream: toggle filtered/all | Docs: open in vim
12
12
  * Esc — return to Command Mode
13
- * Ctrl+C — quit
14
13
  */
15
14
 
16
15
  import React, { useState, useEffect, useMemo } from 'react';
17
16
  import { Box, Text, useInput } from 'ink';
17
+ import fs from 'node:fs';
18
+ import { execSync } from 'node:child_process';
18
19
  import { OrgTree } from './OrgTree';
19
20
  import { StreamView } from './StreamView';
20
21
  import type { OrgNode } from '../store';
21
22
  import type { SSEEvent, ActiveSessionInfo, SessionInfo } from '../api';
22
23
  import type { WaveInfo } from '../hooks/useCommand';
23
24
 
25
+ type RightTab = 'stream' | 'docs' | 'info';
26
+
24
27
  interface PanelModeProps {
25
28
  tree: OrgNode[];
26
29
  flatRoles: string[];
@@ -39,7 +42,6 @@ interface PanelModeProps {
39
42
  onFocusWave: (waveId: string) => void;
40
43
  }
41
44
 
42
- /** Get wave-scoped role statuses */
43
45
  function getWaveScopedStatuses(
44
46
  allSessions: SessionInfo[],
45
47
  focusedWaveId: string | null,
@@ -48,30 +50,22 @@ function getWaveScopedStatuses(
48
50
  const statuses: Record<string, string> = {};
49
51
  for (const s of allSessions) {
50
52
  if (s.waveId !== focusedWaveId) continue;
51
- if (s.status === 'active') {
52
- statuses[s.roleId] = 'working';
53
- } else if (!statuses[s.roleId]) {
54
- statuses[s.roleId] = 'done';
55
- }
53
+ if (s.status === 'active') statuses[s.roleId] = 'working';
54
+ else if (!statuses[s.roleId]) statuses[s.roleId] = 'done';
56
55
  }
57
56
  return statuses;
58
57
  }
59
58
 
60
- /** Find active session for a role in focused wave */
61
59
  function findSessionForRole(
62
60
  activeSessions: ActiveSessionInfo[],
63
61
  allSessions: SessionInfo[],
64
62
  roleId: string,
65
63
  focusedWaveId: string | null,
66
64
  ): ActiveSessionInfo | null {
67
- // First try: session with matching waveId
68
65
  if (focusedWaveId) {
69
66
  const waveSes = allSessions.find(s => s.waveId === focusedWaveId && s.roleId === roleId && s.status === 'active');
70
- if (waveSes) {
71
- return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
72
- }
67
+ if (waveSes) return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
73
68
  }
74
- // Fallback: any active session for this role
75
69
  return activeSessions.find(s => s.roleId === roleId && s.status === 'active') ?? null;
76
70
  }
77
71
 
@@ -82,24 +76,41 @@ function elapsed(startedAt: string): string {
82
76
  return `${Math.floor(ms / 3600_000)}h`;
83
77
  }
84
78
 
79
+ /** Extract files created/modified in this wave from SSE events */
80
+ function extractWaveFiles(events: SSEEvent[]): string[] {
81
+ const files = new Set<string>();
82
+ for (const e of events) {
83
+ if (e.type === 'tool:start') {
84
+ const name = (e.data.name as string) ?? '';
85
+ const input = e.data.input as Record<string, unknown> | undefined;
86
+ if (['Write', 'Edit', 'NotebookEdit'].includes(name) && input?.file_path) {
87
+ files.add(String(input.file_path));
88
+ }
89
+ }
90
+ }
91
+ return Array.from(files);
92
+ }
93
+
94
+ /** Read file preview (first N lines) */
95
+ function readFilePreview(filePath: string, maxLines: number): string[] {
96
+ try {
97
+ const content = fs.readFileSync(filePath, 'utf-8');
98
+ return content.split('\n').slice(0, maxLines);
99
+ } catch {
100
+ return ['(cannot read file)'];
101
+ }
102
+ }
103
+
85
104
  export const PanelMode: React.FC<PanelModeProps> = ({
86
- tree,
87
- flatRoles,
88
- events,
89
- selectedRoleIndex,
90
- selectedRoleId,
91
- streamStatus,
92
- waveId,
93
- activeSessions,
94
- allSessions,
95
- waves,
96
- focusedWaveId,
97
- onMove,
98
- onSelect,
99
- onEscape,
100
- onFocusWave,
105
+ tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
106
+ streamStatus, waveId, activeSessions, allSessions, waves,
107
+ focusedWaveId, onMove, onSelect, onEscape, onFocusWave,
101
108
  }) => {
102
109
  const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
110
+ const [rightTab, setRightTab] = useState<RightTab>('stream');
111
+ const [docsIndex, setDocsIndex] = useState(0);
112
+ const [docsScroll, setDocsScroll] = useState(0);
113
+
103
114
  useEffect(() => {
104
115
  const onResize = () => setTermHeight(process.stdout.rows || 30);
105
116
  process.stdout.on('resize', onResize);
@@ -108,13 +119,11 @@ export const PanelMode: React.FC<PanelModeProps> = ({
108
119
 
109
120
  const separatorStr = useMemo(() => '\u2502\n'.repeat(Math.max(5, termHeight - 8)), [termHeight]);
110
121
 
111
- // Wave-scoped statuses for Org Tree
112
122
  const waveScopedStatuses = useMemo(
113
123
  () => getWaveScopedStatuses(allSessions, focusedWaveId),
114
124
  [allSessions, focusedWaveId],
115
125
  );
116
126
 
117
- // Override tree node statuses with wave-scoped values
118
127
  const waveScopedTree = useMemo(() => {
119
128
  function scopeNode(node: OrgNode): OrgNode {
120
129
  return {
@@ -126,11 +135,74 @@ export const PanelMode: React.FC<PanelModeProps> = ({
126
135
  return tree.map(scopeNode);
127
136
  }, [tree, waveScopedStatuses]);
128
137
 
138
+ // Files created in this wave
139
+ const waveFiles = useMemo(() => extractWaveFiles(events), [events]);
140
+
141
+ // File preview for selected doc
142
+ const selectedFile = waveFiles[docsIndex] ?? null;
143
+ const filePreview = useMemo(() => {
144
+ if (!selectedFile || rightTab !== 'docs') return [];
145
+ return readFilePreview(selectedFile, 100);
146
+ }, [selectedFile, rightTab]);
147
+
129
148
  useInput((input, key) => {
130
149
  if (key.escape) { onEscape(); return; }
131
- if (key.upArrow || input === 'k') { onMove('up'); return; }
132
- if (key.downArrow || input === 'j') { onMove('down'); return; }
133
- if (key.return) { onSelect(); return; }
150
+
151
+ // h/l: switch right panel tab
152
+ if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
153
+ const tabs: RightTab[] = ['stream', 'docs', 'info'];
154
+ const idx = tabs.indexOf(rightTab);
155
+ if (idx > 0) { setRightTab(tabs[idx - 1]); setDocsScroll(0); }
156
+ return;
157
+ }
158
+ if (input === 'l' || (key.rightArrow && rightTab !== 'info')) {
159
+ const tabs: RightTab[] = ['stream', 'docs', 'info'];
160
+ const idx = tabs.indexOf(rightTab);
161
+ if (idx < tabs.length - 1) { setRightTab(tabs[idx + 1]); setDocsScroll(0); }
162
+ return;
163
+ }
164
+
165
+ // j/k: context-dependent
166
+ if (key.upArrow || input === 'k') {
167
+ if (rightTab === 'docs' && docsScroll > 0) {
168
+ setDocsScroll(s => Math.max(0, s - 3));
169
+ } else if (rightTab === 'stream') {
170
+ onMove('up');
171
+ }
172
+ return;
173
+ }
174
+ if (key.downArrow || input === 'j') {
175
+ if (rightTab === 'docs') {
176
+ setDocsScroll(s => s + 3);
177
+ } else if (rightTab === 'stream') {
178
+ onMove('down');
179
+ }
180
+ return;
181
+ }
182
+
183
+ // Tab key for cycling docs files
184
+ if (key.tab && rightTab === 'docs') {
185
+ setDocsIndex(i => (i + 1) % Math.max(1, waveFiles.length));
186
+ setDocsScroll(0);
187
+ return;
188
+ }
189
+
190
+ // Enter
191
+ if (key.return) {
192
+ if (rightTab === 'docs' && selectedFile) {
193
+ // Open in vim
194
+ const editor = process.env.EDITOR || 'vim';
195
+ try {
196
+ execSync(`${editor} "${selectedFile}"`, { stdio: 'inherit' });
197
+ } catch { /* user quit editor */ }
198
+ return;
199
+ }
200
+ if (rightTab === 'stream') {
201
+ onSelect();
202
+ return;
203
+ }
204
+ }
205
+
134
206
  // 1-9: wave switch
135
207
  const num = parseInt(input, 10);
136
208
  if (num >= 1 && num <= 9 && num <= waves.length) {
@@ -147,18 +219,15 @@ export const PanelMode: React.FC<PanelModeProps> = ({
147
219
  ? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
148
220
  : 'All';
149
221
 
150
- // Find resource info for selected role (wave-scoped)
151
222
  const selectedSession = selectedRoleId
152
223
  ? findSessionForRole(activeSessions, allSessions, selectedRoleId, focusedWaveId)
153
224
  : null;
154
225
 
155
- // Focused wave info
156
226
  const focusedWave = waves.find(w => w.waveId === focusedWaveId);
157
227
  const focusedWaveIndex = focusedWaveId
158
228
  ? waves.findIndex(w => w.waveId === focusedWaveId) + 1
159
229
  : 0;
160
230
 
161
- // Wave session count for display
162
231
  const waveSessionCount = focusedWaveId
163
232
  ? allSessions.filter(s => s.waveId === focusedWaveId).length
164
233
  : 0;
@@ -167,42 +236,41 @@ export const PanelMode: React.FC<PanelModeProps> = ({
167
236
 
168
237
  return (
169
238
  <Box flexDirection="column" flexGrow={1}>
170
- {/* Main content */}
171
239
  <Box flexGrow={1}>
172
240
  {/* Left: Wave title + Org Tree + Wave tabs */}
173
241
  <Box flexDirection="column" width={leftWidth}>
174
- {/* Wave title */}
175
- {focusedWaveIndex > 0 && (
176
- <Box paddingX={1} marginBottom={0}>
177
- <Text color="green" bold>Wave {focusedWaveIndex}</Text>
178
- {waveSessionCount > 0 && <Text color="gray"> ({waveSessionCount})</Text>}
242
+ <Box paddingX={1}>
243
+ <Text color="green" bold>W{focusedWaveIndex}</Text>
244
+ <Text color="gray"> </Text>
245
+ <Text color="white" wrap="truncate">
246
+ {focusedWave?.directive ? focusedWave.directive.slice(0, leftWidth - 6) : '(idle)'}
247
+ </Text>
248
+ </Box>
249
+ {waveSessionCount > 0 && (
250
+ <Box paddingX={1}>
251
+ <Text color="gray">{waveSessionCount} sessions</Text>
179
252
  </Box>
180
253
  )}
181
254
 
182
- {/* Org Tree (wave-scoped statuses) */}
183
255
  <OrgTree
184
256
  tree={waveScopedTree}
185
- focused={true}
257
+ focused={rightTab === 'stream'}
186
258
  selectedIndex={selectedRoleIndex}
187
259
  flatRoles={flatRoles}
188
260
  ceoStatus={waveScopedStatuses['ceo'] ?? 'idle'}
189
261
  />
190
262
 
191
- {/* Wave tabs at bottom — compact inline */}
192
263
  {waves.length > 1 && (
193
264
  <Box paddingX={1} marginTop={1}>
194
- <Text color="gray">W </Text>
195
- {waves.map((w, i) => {
196
- const isFocused = w.waveId === focusedWaveId;
197
- return (
198
- <Text key={w.waveId}
199
- color={isFocused ? 'green' : 'gray'}
200
- bold={isFocused}
201
- >
202
- {isFocused ? `[${i + 1}]` : ` ${i + 1} `}
203
- </Text>
204
- );
205
- })}
265
+ {waves.map((w, i) => (
266
+ <Box key={w.waveId} marginRight={1}>
267
+ <Text
268
+ color={w.waveId === focusedWaveId ? 'green' : 'gray'}
269
+ bold={w.waveId === focusedWaveId}
270
+ inverse={w.waveId === focusedWaveId}
271
+ >{` ${i + 1} `}</Text>
272
+ </Box>
273
+ ))}
206
274
  </Box>
207
275
  )}
208
276
  </Box>
@@ -212,71 +280,149 @@ export const PanelMode: React.FC<PanelModeProps> = ({
212
280
  <Text color="gray">{separatorStr}</Text>
213
281
  </Box>
214
282
 
215
- {/* Right: Agent Detail + Stream */}
283
+ {/* Right: Tabbed panel */}
216
284
  <Box flexGrow={1} flexDirection="column" overflow="hidden">
217
- {/* Agent Resource Header */}
218
- {selectedRoleId && selectedSession && (
219
- <Box flexDirection="column" paddingX={1} marginBottom={0}>
220
- <Box justifyContent="space-between">
221
- <Text bold color="cyan">{selectedRoleId}</Text>
222
- <Text color={selectedSession.status === 'active' ? 'green' : 'gray'}>
223
- {selectedSession.status === 'active' ? '\u25CF' : '\u25CB'} {selectedSession.status}
224
- {selectedSession.startedAt ? ` (${elapsed(selectedSession.startedAt)})` : ''}
285
+ {/* Tab bar */}
286
+ <Box paddingX={1} marginBottom={0}>
287
+ {(['stream', 'docs', 'info'] as RightTab[]).map(tab => (
288
+ <Box key={tab} marginRight={1}>
289
+ <Text
290
+ color={rightTab === tab ? 'cyan' : 'gray'}
291
+ bold={rightTab === tab}
292
+ inverse={rightTab === tab}
293
+ >
294
+ {` ${tab.charAt(0).toUpperCase() + tab.slice(1)} `}
225
295
  </Text>
226
296
  </Box>
227
- {selectedSession.ports.api > 0 && (
228
- <Box>
229
- <Text color="gray">Port </Text>
230
- <Text color="white">
231
- API:{selectedSession.ports.api} Vite:{selectedSession.ports.vite}
232
- {selectedSession.ports.hmr ? ` HMR:${selectedSession.ports.hmr}` : ''}
233
- </Text>
297
+ ))}
298
+ <Text color="gray" dimColor> [h/l] switch</Text>
299
+ </Box>
300
+
301
+ {/* Stream tab */}
302
+ {rightTab === 'stream' && (
303
+ <>
304
+ {selectedRoleId && selectedSession && (
305
+ <Box flexDirection="column" paddingX={1}>
306
+ <Box justifyContent="space-between">
307
+ <Text bold color="cyan">{selectedRoleId}</Text>
308
+ <Text color={selectedSession.status === 'active' ? 'green' : 'gray'}>
309
+ {selectedSession.status === 'active' ? '\u25CF' : '\u25CB'} {selectedSession.status}
310
+ {selectedSession.startedAt ? ` (${elapsed(selectedSession.startedAt)})` : ''}
311
+ </Text>
312
+ </Box>
313
+ {selectedSession.ports.api > 0 && (
314
+ <Text color="gray">Port API:{selectedSession.ports.api} Vite:{selectedSession.ports.vite}</Text>
315
+ )}
316
+ <Text color="gray">{'\u2500'.repeat(40)}</Text>
234
317
  </Box>
235
318
  )}
236
- {selectedSession.worktreePath && (
237
- <Box>
238
- <Text color="gray">Tree </Text>
239
- <Text color="white">{selectedSession.worktreePath}</Text>
319
+ {selectedRoleId && !selectedSession && (
320
+ <Box flexDirection="column" paddingX={1}>
321
+ <Text bold color="cyan">{selectedRoleId}</Text>
322
+ <Text color="gray">(not active in this wave)</Text>
323
+ <Text color="gray">{'\u2500'.repeat(40)}</Text>
240
324
  </Box>
241
325
  )}
242
- {selectedSession.task && (
243
- <Box>
244
- <Text color="gray">Task </Text>
245
- <Text color="white" wrap="truncate">{selectedSession.task.slice(0, 60)}</Text>
246
- </Box>
326
+ <StreamView
327
+ events={roleEvents}
328
+ allRoleIds={flatRoles}
329
+ streamStatus={streamStatus}
330
+ waveId={waveId}
331
+ roleLabel={roleLabel}
332
+ />
333
+ </>
334
+ )}
335
+
336
+ {/* Docs tab */}
337
+ {rightTab === 'docs' && (
338
+ <Box flexDirection="column" paddingX={1} flexGrow={1}>
339
+ {waveFiles.length === 0 ? (
340
+ <Text color="gray">No files created in this wave yet.</Text>
341
+ ) : (
342
+ <>
343
+ {/* File list */}
344
+ <Box marginBottom={1}>
345
+ <Text color="gray">Files ({waveFiles.length}): </Text>
346
+ {waveFiles.map((f, i) => (
347
+ <Box key={f} marginRight={1}>
348
+ <Text
349
+ color={i === docsIndex ? 'cyan' : 'gray'}
350
+ bold={i === docsIndex}
351
+ inverse={i === docsIndex}
352
+ >
353
+ {` ${f.split('/').pop()} `}
354
+ </Text>
355
+ </Box>
356
+ ))}
357
+ <Text color="gray" dimColor> [Tab] next</Text>
358
+ </Box>
359
+
360
+ {/* File preview */}
361
+ {selectedFile && (
362
+ <Box flexDirection="column">
363
+ <Text color="cyan" bold>{selectedFile.split('/').slice(-2).join('/')}</Text>
364
+ <Text color="gray">{'\u2500'.repeat(50)}</Text>
365
+ {filePreview.slice(docsScroll, docsScroll + termHeight - 12).map((line, i) => (
366
+ <Text key={i} color="white" wrap="wrap">{line}</Text>
367
+ ))}
368
+ {filePreview.length > termHeight - 12 && (
369
+ <Text color="gray" dimColor>
370
+ {docsScroll > 0 ? '\u2191 ' : ''}j/k scroll | {filePreview.length - docsScroll} lines remaining
371
+ </Text>
372
+ )}
373
+ </Box>
374
+ )}
375
+
376
+ <Box marginTop={1}>
377
+ <Text color="gray" dimColor>[Enter] open in {process.env.EDITOR || 'vim'} | [Tab] next file | [j/k] scroll</Text>
378
+ </Box>
379
+ </>
247
380
  )}
248
- <Text color="gray">{'\u2500'.repeat(40)}</Text>
249
381
  </Box>
250
382
  )}
251
383
 
252
- {selectedRoleId && !selectedSession && (
253
- <Box flexDirection="column" paddingX={1} marginBottom={0}>
254
- <Text bold color="cyan">{selectedRoleId}</Text>
255
- <Text color="gray">(not active in this wave)</Text>
384
+ {/* Info tab */}
385
+ {rightTab === 'info' && (
386
+ <Box flexDirection="column" paddingX={1}>
387
+ <Text bold color="cyan">Wave Info</Text>
256
388
  <Text color="gray">{'\u2500'.repeat(40)}</Text>
389
+ <Text color="white">Wave: {focusedWave?.waveId ?? 'none'}</Text>
390
+ <Text color="white">Directive: {focusedWave?.directive || '(idle)'}</Text>
391
+ <Text color="white">Sessions: {waveSessionCount}</Text>
392
+ <Text color="white">Files modified: {waveFiles.length}</Text>
393
+ <Text color="white">SSE events: {events.length}</Text>
394
+
395
+ {/* Active sessions in this wave */}
396
+ {waveSessionCount > 0 && (
397
+ <>
398
+ <Text color="gray" bold>{'\n'}Active in this wave:</Text>
399
+ {allSessions
400
+ .filter(s => s.waveId === focusedWaveId && s.status === 'active')
401
+ .slice(0, 10)
402
+ .map(s => {
403
+ const port = activeSessions.find(a => a.sessionId === s.id);
404
+ return (
405
+ <Text key={s.id} color="white">
406
+ {` ${s.roleId.padEnd(12)} ${s.id.slice(0, 20)} ${port ? `API:${port.ports.api}` : ''}`}
407
+ </Text>
408
+ );
409
+ })
410
+ }
411
+ </>
412
+ )}
257
413
  </Box>
258
414
  )}
259
-
260
- {/* Stream */}
261
- <StreamView
262
- events={roleEvents}
263
- allRoleIds={flatRoles}
264
- streamStatus={streamStatus}
265
- waveId={waveId}
266
- roleLabel={roleLabel}
267
- />
268
415
  </Box>
269
416
  </Box>
270
417
 
271
- {/* Separator */}
418
+ {/* Footer */}
272
419
  <Box width="100%">
273
420
  <Text color="gray">{'\u2500'.repeat(process.stdout.columns || 70)}</Text>
274
421
  </Box>
275
-
276
- {/* Footer hints */}
277
422
  <Box paddingX={1} justifyContent="center">
278
423
  <Text color="gray" dimColor>
279
- [j/k] move [Enter] all/role {waves.length > 1 ? '[1-9] wave ' : ''}[Esc] command [Ctrl+C] quit
424
+ [h/l] tab [j/k] {rightTab === 'stream' ? 'role' : 'scroll'} {rightTab === 'docs' ? '[Enter] vim ' : ''}
425
+ {waves.length > 1 ? '[1-9] wave ' : ''}[Esc] command
280
426
  </Text>
281
427
  </Box>
282
428
  </Box>
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { useCallback } from 'react';
19
- import { dispatchWave, sendDirective, fetchJson, killSession, cleanupSessions } from '../api';
19
+ import { dispatchWave, sendDirective, fetchJson, killSession, cleanupSessions, fetchActiveSessions } from '../api';
20
20
 
21
21
  export interface WaveInfo {
22
22
  waveId: string;
@@ -25,7 +25,7 @@ export interface WaveInfo {
25
25
  }
26
26
 
27
27
  export interface CommandResult {
28
- type: 'success' | 'error' | 'info' | 'wave_started' | 'directive_sent' | 'stopped' | 'quit' | 'help' | 'panel' | 'waves_list' | 'focus_changed' | 'agents' | 'ports' | 'sessions' | 'cleanup';
28
+ type: 'success' | 'error' | 'info' | 'wave_started' | 'directive_sent' | 'stopped' | 'quit' | 'help' | 'panel' | 'waves_list' | 'focus_changed' | 'agents' | 'ports' | 'sessions' | 'cleanup' | 'docs' | 'read_file' | 'open_file';
29
29
  message: string;
30
30
  waveId?: string;
31
31
  }
@@ -123,6 +123,19 @@ export function useCommand(options: UseCommandOptions) {
123
123
  }
124
124
  }
125
125
 
126
+ case 'docs':
127
+ return { type: 'docs', message: '__docs__' };
128
+
129
+ case 'read': {
130
+ if (!args) return { type: 'error', message: 'Usage: /read <file_path>' };
131
+ return { type: 'read_file', message: args.trim() };
132
+ }
133
+
134
+ case 'open': {
135
+ if (!args) return { type: 'error', message: 'Usage: /open <file_path>' };
136
+ return { type: 'open_file', message: args.trim() };
137
+ }
138
+
126
139
  case 'status':
127
140
  return { type: 'info', message: '__status__' };
128
141