tycono 0.3.14-beta.15 → 0.3.14-beta.17

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.3.14-beta.15",
3
+ "version": "0.3.14-beta.17",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/tui/app.tsx CHANGED
@@ -14,8 +14,6 @@
14
14
  import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
15
15
  import { Box, Text, useApp, useInput } from 'ink';
16
16
  import { StatusBar } from './components/StatusBar';
17
- import { OrgTree } from './components/OrgTree';
18
- import { StreamView } from './components/StreamView';
19
17
  import { CommandMode, type StreamLine } from './components/CommandMode';
20
18
  import { PanelMode } from './components/PanelMode';
21
19
  import { SetupWizard } from './components/SetupWizard';
@@ -670,49 +668,6 @@ export const App: React.FC = () => {
670
668
  // Command Mode: scrollable terminal (no fullscreen)
671
669
  // Panel Mode: fullscreen (intentional — like vim for inspection)
672
670
  if (mode === 'panel') {
673
- // OOM debug levels: 0=full, 1=minimal, 2=orgTree only, 3=stream only
674
- const debugLevel = parseInt(process.env.PANEL_MINIMAL || '0', 10);
675
- if (debugLevel === 1) {
676
- return (
677
- <Box flexDirection="column">
678
- <Text color="cyan">Panel Mode (minimal)</Text>
679
- <Text color="gray">Events: {sse.events.length} | Press Esc</Text>
680
- </Box>
681
- );
682
- }
683
- if (debugLevel === 2) {
684
- return (
685
- <Box flexDirection="column">
686
- <OrgTree tree={orgTree} focused={true} selectedIndex={0} flatRoles={flatRoleIds} ceoStatus="idle" />
687
- <Text color="gray">OrgTree only | Press Esc</Text>
688
- </Box>
689
- );
690
- }
691
- if (debugLevel === 3) {
692
- return (
693
- <Box flexDirection="column">
694
- <StreamView events={sse.events} allRoleIds={flatRoleIds} streamStatus={sse.streamStatus} waveId={focusedWaveId} roleLabel="All" />
695
- <Text color="gray">StreamView only | Press Esc</Text>
696
- </Box>
697
- );
698
- }
699
- if (debugLevel === 4) {
700
- // Full layout structure but empty content
701
- return (
702
- <Box flexDirection="column" height={termHeight}>
703
- <Box flexGrow={1}>
704
- <Box flexDirection="column" width={28}>
705
- <Text color="green">Left Panel</Text>
706
- </Box>
707
- <Text color="gray">{'\u2502'}</Text>
708
- <Box flexGrow={1} flexDirection="column" overflow="hidden">
709
- <Text color="cyan">Right Panel</Text>
710
- </Box>
711
- </Box>
712
- <StatusBar companyName="test" waveIndex={1} waveCount={1} waveStatus="idle" activeCount={0} portCount={0} totalCost={0} />
713
- </Box>
714
- );
715
- }
716
671
  return (
717
672
  <Box flexDirection="column">
718
673
  <Box flexDirection="column">
@@ -1,30 +1,20 @@
1
1
  /**
2
- * PanelMode — Wave-scoped team view with right-panel tabs
2
+ * PanelMode — Wave-scoped team view (text-based render)
3
3
  *
4
- * Left: Wave title + Org Tree (wave-scoped) + Wave tabs
5
- * Right: [Stream] [Docs] [Info] tab switching with h/l
6
- *
7
- * Navigation:
8
- * j/k — move in Org Tree (auto-selects) or scroll in Docs
9
- * h/l — switch right panel tab
10
- * 1-9 — switch wave focus
11
- * Enter — Stream: toggle filtered/all | Docs: open in vim
12
- * Esc — return to Command Mode
4
+ * yoga-layout OOMs on 245+ column terminals with nested Box.
5
+ * Solution: flat <Text> elements only, no Box nesting beyond 1 level.
6
+ * Layout is string-based with manual padding.
13
7
  */
14
8
 
15
9
  import React, { useState, useEffect, useMemo } from 'react';
16
10
  import { Box, Text, useInput } from 'ink';
17
11
  import fs from 'node:fs';
18
- import { execSync } from 'node:child_process';
19
- import { OrgTree } from './OrgTree';
20
- import { StreamView } from './StreamView';
21
- import type { OrgNode } from '../store';
22
12
  import path from 'node:path';
13
+ import type { OrgNode } from '../store';
23
14
  import type { SSEEvent, ActiveSessionInfo, SessionInfo } from '../api';
24
15
  import type { WaveInfo } from '../hooks/useCommand';
25
16
 
26
17
  type RightTab = 'stream' | 'docs' | 'info';
27
- type DocsFilter = 'all' | 'wave' | 'kb' | 'projects';
28
18
 
29
19
  interface PanelModeProps {
30
20
  tree: OrgNode[];
@@ -45,8 +35,21 @@ interface PanelModeProps {
45
35
  onFocusWave: (waveId: string) => void;
46
36
  }
47
37
 
48
- function flattenTreeForText(nodes: OrgNode[], isLast: boolean[] = []): string[] {
49
- const lines: string[] = [];
38
+ /* ─── Helpers ─── */
39
+
40
+ function getWaveScopedStatuses(sessions: SessionInfo[], waveId: string | null): Record<string, string> {
41
+ if (!waveId) return {};
42
+ const s: Record<string, string> = {};
43
+ for (const ses of sessions) {
44
+ if (ses.waveId !== waveId) continue;
45
+ if (ses.status === 'active') s[ses.roleId] = 'working';
46
+ else if (!s[ses.roleId]) s[ses.roleId] = 'done';
47
+ }
48
+ return s;
49
+ }
50
+
51
+ function flattenTree(nodes: OrgNode[], isLast: boolean[] = []): Array<{ roleId: string; status: string; line: string }> {
52
+ const result: Array<{ roleId: string; status: string; line: string }> = [];
50
53
  for (let i = 0; i < nodes.length; i++) {
51
54
  const node = nodes[i];
52
55
  const last = i === nodes.length - 1;
@@ -54,122 +57,32 @@ function flattenTreeForText(nodes: OrgNode[], isLast: boolean[] = []): string[]
54
57
  for (const l of isLast) prefix += l ? ' ' : '\u2502 ';
55
58
  prefix += last ? '\u2514\u2500 ' : '\u251C\u2500 ';
56
59
  const icon = node.status === 'working' ? '\u25CF' : node.status === 'done' ? '\u2713' : '\u25CB';
57
- lines.push(`${prefix}${icon} ${node.role.id}`);
58
- if (node.children.length > 0) lines.push(...flattenTreeForText(node.children, [...isLast, last]));
59
- }
60
- return lines;
61
- }
62
-
63
- function eventToOneLiner(event: SSEEvent): string | null {
64
- const time = new Date(event.ts).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
65
- const role = event.roleId.padEnd(12);
66
- switch (event.type) {
67
- case 'text': { const t = ((event.data.text as string) ?? '').trim(); return t ? `${time} ${role} ${t.slice(0, 100)}` : null; }
68
- case 'thinking': { const t = ((event.data.text as string) ?? '').trim(); return t ? `${time} ${role} \uD83D\uDCAD ${t.slice(0, 80)}` : null; }
69
- case 'tool:start': { const n = (event.data.name as string) ?? ''; return `${time} ${role} \u2192 ${n}`; }
70
- case 'msg:start': return `${time} ${role} \u25B6 Started`;
71
- case 'msg:done': return `${time} ${role} \u2713 Done`;
72
- case 'msg:error': return `${time} ${role} \u2717 Error`;
73
- case 'dispatch:start': return `${time} ${role} \u21D2 ${event.data.targetRole as string ?? ''}`;
74
- case 'tool:result': return `${time} ${role} \u2190 ${(event.data.name as string) ?? 'done'}`;
75
- case 'msg:awaiting_input': return `${time} ${role} ? awaiting input`;
76
- default: return null; // skip internal: turn-complete, trace, heartbeat
77
- }
78
- }
79
-
80
- function getWaveScopedStatuses(
81
- allSessions: SessionInfo[],
82
- focusedWaveId: string | null,
83
- ): Record<string, string> {
84
- if (!focusedWaveId) return {};
85
- const statuses: Record<string, string> = {};
86
- for (const s of allSessions) {
87
- if (s.waveId !== focusedWaveId) continue;
88
- if (s.status === 'active') statuses[s.roleId] = 'working';
89
- else if (!statuses[s.roleId]) statuses[s.roleId] = 'done';
90
- }
91
- return statuses;
92
- }
93
-
94
- function findSessionForRole(
95
- activeSessions: ActiveSessionInfo[],
96
- allSessions: SessionInfo[],
97
- roleId: string,
98
- focusedWaveId: string | null,
99
- ): ActiveSessionInfo | null {
100
- if (focusedWaveId) {
101
- const waveSes = allSessions.find(s => s.waveId === focusedWaveId && s.roleId === roleId && s.status === 'active');
102
- if (waveSes) return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
103
- }
104
- return activeSessions.find(s => s.roleId === roleId && s.status === 'active') ?? null;
105
- }
106
-
107
- function elapsed(startedAt: string): string {
108
- const ms = Date.now() - new Date(startedAt).getTime();
109
- if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
110
- if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m`;
111
- return `${Math.floor(ms / 3600_000)}h`;
112
- }
113
-
114
- /** Scan COMPANY_ROOT for .md files (cached) */
115
- let mdFileCache: { root: string; files: string[] } | null = null;
116
- function scanMdFiles(companyRoot: string): string[] {
117
- if (mdFileCache && mdFileCache.root === companyRoot) return mdFileCache.files;
118
- const results: string[] = [];
119
- const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude']);
120
- function walk(dir: string, depth: number) {
121
- if (depth > 3) return; // Don't go too deep
122
- try {
123
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
124
- if (skip.has(entry.name)) continue;
125
- const full = path.join(dir, entry.name);
126
- if (entry.isDirectory()) {
127
- walk(full, depth + 1);
128
- } else if (entry.name.endsWith('.md')) {
129
- results.push(full);
130
- }
131
- }
132
- } catch { /* permission error etc */ }
60
+ result.push({ roleId: node.role.id, status: node.status, line: `${prefix}${icon} ${node.role.id}` });
61
+ if (node.children.length > 0) result.push(...flattenTree(node.children, [...isLast, last]));
133
62
  }
134
- walk(companyRoot, 0);
135
- mdFileCache = { root: companyRoot, files: results };
136
- return results;
63
+ return result;
137
64
  }
138
65
 
139
- /** Extract files created/modified in this wave from SSE events */
140
- function extractWaveFiles(events: SSEEvent[]): string[] {
141
- const files = new Set<string>();
142
- for (const e of events) {
143
- if (e.type === 'tool:start') {
144
- const name = (e.data.name as string) ?? '';
145
- const input = e.data.input as Record<string, unknown> | undefined;
146
- if (['Write', 'Edit', 'NotebookEdit'].includes(name) && input?.file_path) {
147
- files.add(String(input.file_path));
148
- }
149
- }
66
+ function eventLine(ev: SSEEvent): string | null {
67
+ let t: string;
68
+ try { t = new Date(ev.ts).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
69
+ catch { t = '--:--:--'; }
70
+ const r = (ev.roleId ?? '').padEnd(12);
71
+ switch (ev.type) {
72
+ case 'text': { const x = ((ev.data.text as string) ?? '').trim(); return x ? `${t} ${r} ${x.slice(0, 120)}` : null; }
73
+ case 'thinking': { const x = ((ev.data.text as string) ?? '').trim(); return x ? `${t} ${r} \uD83D\uDCAD ${x.slice(0, 80)}` : null; }
74
+ case 'tool:start': { const n = (ev.data.name as string) ?? ''; const d = ev.data.input ? (((ev.data.input as any).file_path || (ev.data.input as any).command || (ev.data.input as any).pattern || '') as string).slice(0, 50) : ''; return `${t} ${r} \u2192 ${n} ${d}`; }
75
+ case 'tool:result': return `${t} ${r} \u2190 ${(ev.data.name as string) ?? ''} done`;
76
+ case 'msg:start': return `${t} ${r} \u25B6 Started`;
77
+ case 'msg:done': { const turns = ev.data.turns as number | undefined; return `${t} ${r} \u2713 Done${turns ? ` (${turns} turns)` : ''}`; }
78
+ case 'msg:error': return `${t} ${r} \u2717 ${((ev.data.error ?? ev.data.message) as string ?? '').slice(0, 60)}`;
79
+ case 'dispatch:start': return `${t} ${r} \u21D2 dispatch ${ev.data.targetRole as string ?? ''}`;
80
+ case 'msg:awaiting_input': return `${t} ${r} ? awaiting input`;
81
+ default: return null;
150
82
  }
151
- return Array.from(files);
152
83
  }
153
84
 
154
- /** Read file preview (first N lines, cached) */
155
- const fileCache = new Map<string, string[]>();
156
- function readFilePreview(filePath: string, maxLines: number): string[] {
157
- const cached = fileCache.get(filePath);
158
- if (cached) return cached;
159
- try {
160
- const content = fs.readFileSync(filePath, 'utf-8');
161
- const lines = content.split('\n').slice(0, maxLines);
162
- fileCache.set(filePath, lines);
163
- // Evict old entries
164
- if (fileCache.size > 5) {
165
- const first = fileCache.keys().next().value;
166
- if (first) fileCache.delete(first);
167
- }
168
- return lines;
169
- } catch {
170
- return ['(cannot read file)'];
171
- }
172
- }
85
+ /* ─── Component ─── */
173
86
 
174
87
  const PanelModeInner: React.FC<PanelModeProps> = ({
175
88
  tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
@@ -178,295 +91,173 @@ const PanelModeInner: React.FC<PanelModeProps> = ({
178
91
  }) => {
179
92
  const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
180
93
  const [rightTab, setRightTab] = useState<RightTab>('stream');
181
- const [docsFilter, setDocsFilter] = useState<DocsFilter>('all');
182
- const [docsIndex, setDocsIndex] = useState(0);
183
- const [docsScroll, setDocsScroll] = useState(0);
184
94
 
185
95
  useEffect(() => {
186
- const onResize = () => setTermHeight(process.stdout.rows || 30);
187
- process.stdout.on('resize', onResize);
188
- return () => { process.stdout.off('resize', onResize); };
96
+ const fn = () => setTermHeight(process.stdout.rows || 30);
97
+ process.stdout.on('resize', fn);
98
+ return () => { process.stdout.off('resize', fn); };
189
99
  }, []);
190
100
 
191
- // OOM fix: single separator character instead of repeated newlines
192
- // Previous: '│\n'.repeat(30) created 30 yoga nodes → layout explosion on large terminals
193
- const separatorStr = '\u2502';
194
-
195
- const waveScopedStatuses = useMemo(
196
- () => getWaveScopedStatuses(allSessions, focusedWaveId),
197
- [allSessions, focusedWaveId],
198
- );
199
-
200
- const waveScopedTree = useMemo(() => {
201
- function scopeNode(node: OrgNode): OrgNode {
202
- return {
203
- ...node,
204
- status: waveScopedStatuses[node.role.id] ?? 'idle',
205
- children: node.children.map(scopeNode),
206
- };
207
- }
208
- return tree.map(scopeNode);
209
- }, [tree, waveScopedStatuses]);
210
-
211
- // Wave files (from SSE events) — only compute when needed
212
- const waveFileSet = useMemo(() => {
213
- if (rightTab !== 'docs' && rightTab !== 'info') return new Set<string>();
214
- return new Set(extractWaveFiles(events));
215
- }, [rightTab === 'docs' || rightTab === 'info' ? events.length : 0, rightTab]);
216
-
217
- // Build docs list from filesystem scan + wave files
218
- const docsList = useMemo(() => {
219
- if (rightTab !== 'docs') return [];
220
-
221
- interface DocEntry { path: string; title: string; isWave: boolean; }
222
- const entries: DocEntry[] = [];
223
-
224
- // Scan all .md files from COMPANY_ROOT
225
- const allMdFiles = companyRoot ? scanMdFiles(companyRoot) : [];
226
-
227
- for (const filePath of allMdFiles) {
228
- const rel = filePath.replace(companyRoot + '/', '');
229
- const isWave = waveFileSet.has(filePath);
230
- const isKb = rel.startsWith('knowledge/');
231
- const isProject = rel.startsWith('projects/');
232
-
233
- if (docsFilter === 'wave' && !isWave) continue;
234
- if (docsFilter === 'kb' && !isKb) continue;
235
- if (docsFilter === 'projects' && !isProject) continue;
236
-
237
- entries.push({ path: filePath, title: rel, isWave });
238
- }
239
-
240
- // Wave-only files not already in list (e.g. code files written by agents)
241
- for (const f of waveFileSet) {
242
- if (!entries.some(e => e.path === f)) {
243
- if (docsFilter === 'kb' || docsFilter === 'projects') continue;
244
- entries.push({ path: f, title: f.split('/').pop() || f, isWave: true });
245
- }
246
- }
247
-
248
- // Sort: wave files first, then alphabetical
249
- entries.sort((a, b) => {
250
- if (a.isWave && !b.isWave) return -1;
251
- if (!a.isWave && b.isWave) return 1;
252
- return a.title.localeCompare(b.title);
253
- });
101
+ const statuses = useMemo(() => getWaveScopedStatuses(allSessions, focusedWaveId), [allSessions, focusedWaveId]);
102
+ const scopedTree = useMemo(() => {
103
+ const scope = (n: OrgNode): OrgNode => ({ ...n, status: statuses[n.role.id] ?? 'idle', children: n.children.map(scope) });
104
+ return tree.map(scope);
105
+ }, [tree, statuses]);
254
106
 
255
- return entries;
256
- }, [rightTab, docsFilter, companyRoot, waveFileSet]);
257
-
258
- const selectedDoc = docsList[docsIndex] ?? null;
259
- const filePreview = useMemo(() => {
260
- if (!selectedDoc || rightTab !== 'docs') return [];
261
- return readFilePreview(selectedDoc.path, 60);
262
- }, [selectedDoc?.path, rightTab]);
107
+ const focusedWave = waves.find(w => w.waveId === focusedWaveId);
108
+ const focusedWaveIndex = focusedWaveId ? waves.findIndex(w => w.waveId === focusedWaveId) + 1 : 0;
109
+ const waveSessionCount = focusedWaveId ? allSessions.filter(s => s.waveId === focusedWaveId).length : 0;
263
110
 
111
+ // Key handling
264
112
  useInput((input, key) => {
265
113
  if (key.escape) { onEscape(); return; }
266
-
267
- // h/l: switch right panel tab
268
- if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
114
+ if (input === 'h' || key.leftArrow) {
269
115
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
270
116
  const idx = tabs.indexOf(rightTab);
271
- if (idx > 0) { setRightTab(tabs[idx - 1]); setDocsScroll(0); }
117
+ if (idx > 0) setRightTab(tabs[idx - 1]);
272
118
  return;
273
119
  }
274
- if (input === 'l' || (key.rightArrow && rightTab !== 'info')) {
120
+ if (input === 'l' || key.rightArrow) {
275
121
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
276
122
  const idx = tabs.indexOf(rightTab);
277
- if (idx < tabs.length - 1) { setRightTab(tabs[idx + 1]); setDocsScroll(0); }
278
- return;
279
- }
280
-
281
- // j/k: context-dependent
282
- if (key.upArrow || input === 'k') {
283
- if (rightTab === 'docs') {
284
- if (docsScroll > 0) {
285
- setDocsScroll(s => Math.max(0, s - 3));
286
- } else {
287
- setDocsIndex(i => Math.max(0, i - 1));
288
- }
289
- } else if (rightTab === 'stream') {
290
- onMove('up');
291
- }
292
- return;
293
- }
294
- if (key.downArrow || input === 'j') {
295
- if (rightTab === 'docs') {
296
- if (docsScroll > 0) {
297
- setDocsScroll(s => s + 3);
298
- } else {
299
- setDocsIndex(i => Math.min(docsList.length - 1, i + 1));
300
- }
301
- } else if (rightTab === 'stream') {
302
- onMove('down');
303
- }
123
+ if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
304
124
  return;
305
125
  }
306
-
307
- // Docs filter: 1-4
308
- if (rightTab === 'docs') {
309
- const filters: DocsFilter[] = ['all', 'wave', 'kb', 'projects'];
310
- const fi = parseInt(input, 10);
311
- if (fi >= 1 && fi <= 4) {
312
- setDocsFilter(filters[fi - 1]);
313
- setDocsIndex(0);
314
- setDocsScroll(0);
315
- return;
316
- }
317
- }
318
-
319
- // Tab key for cycling docs files
320
- if (key.tab && rightTab === 'docs') {
321
- setDocsIndex(i => (i + 1) % Math.max(1, docsList.length));
322
- setDocsScroll(0);
323
- return;
324
- }
325
-
326
- // Enter
327
- if (key.return) {
328
- if (rightTab === 'docs' && selectedDoc) {
329
- const editor = process.env.EDITOR || 'vim';
330
- try {
331
- execSync(`${editor} "${selectedDoc.path}"`, { stdio: 'inherit' });
332
- } catch { /* user quit editor */ }
333
- fileCache.delete(selectedDoc.path); // Invalidate cache after edit
334
- return;
335
- }
336
- if (rightTab === 'stream') {
337
- onSelect();
338
- return;
339
- }
340
- }
341
-
342
- // 1-9: wave switch (only in stream/info tabs — docs uses 1-4 for filters)
343
- if (rightTab !== 'docs') {
344
- const num = parseInt(input, 10);
345
- if (num >= 1 && num <= 9 && num <= waves.length) {
346
- onFocusWave(waves[num - 1].waveId);
347
- }
126
+ if (input === 'k' || key.upArrow) { onMove('up'); return; }
127
+ if (input === 'j' || key.downArrow) { onMove('down'); return; }
128
+ if (key.return) { onSelect(); return; }
129
+ // Wave switch 1-9
130
+ const num = parseInt(input, 10);
131
+ if (num >= 1 && num <= 9 && num <= waves.length) {
132
+ onFocusWave(waves[num - 1].waveId);
348
133
  }
349
134
  });
350
135
 
351
- // Filter events for selected role
352
- const roleEvents = selectedRoleId
353
- ? events.filter((e) => e.roleId === selectedRoleId)
354
- : events;
355
-
356
- const roleLabel = selectedRoleId
357
- ? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
358
- : 'All';
359
-
360
- const selectedSession = selectedRoleId
361
- ? findSessionForRole(activeSessions, allSessions, selectedRoleId, focusedWaveId)
362
- : null;
363
-
364
- const focusedWave = waves.find(w => w.waveId === focusedWaveId);
365
- const focusedWaveIndex = focusedWaveId
366
- ? waves.findIndex(w => w.waveId === focusedWaveId) + 1
367
- : 0;
368
-
369
- const waveSessionCount = focusedWaveId
370
- ? allSessions.filter(s => s.waveId === focusedWaveId).length
371
- : 0;
372
-
373
- // Read preset from wave file on disk
374
- const wavePreset = useMemo(() => {
375
- if (!focusedWaveId || !companyRoot) return null;
376
- try {
377
- const wavePath = path.join(companyRoot, 'operations', 'waves', `${focusedWaveId}.json`);
378
- if (fs.existsSync(wavePath)) {
379
- const data = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
380
- return data.preset as string | undefined;
381
- }
382
- } catch { /* ignore */ }
383
- return null;
384
- }, [focusedWaveId, companyRoot]);
385
-
386
136
  const leftWidth = 28;
387
137
  const termCols = process.stdout.columns || 120;
388
- const rightWidth = Math.max(40, termCols - leftWidth - 3);
389
-
390
- // === Build panel as line arrays, render each line as <Text> ===
391
- // yoga-layout OOMs with nested Box on 245+ columns.
392
- // Solution: flat list of <Text> elements (1 yoga node per line, no nesting)
393
-
394
- // Left: OrgTree
395
- const ceoIcon = waveScopedStatuses['ceo'] === 'working' ? '\u25CF' : waveScopedStatuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
396
- const treeLines = [`${ceoIcon} CEO`, ...flattenTreeForText(waveScopedTree)];
397
-
398
- // Right: Stream/Info content
399
- const rightLines: string[] = [];
138
+ const rightWidth = termCols - leftWidth - 3;
139
+
140
+ // === Build left column: OrgTree ===
141
+ const ceoIcon = statuses['ceo'] === 'working' ? '\u25CF' : statuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
142
+ const isCeoSelected = flatRoles[selectedRoleIndex] === 'ceo';
143
+ const treeEntries = flattenTree(scopedTree);
144
+
145
+ const leftLines: Array<{ text: string; selected: boolean; working: boolean }> = [
146
+ { text: `${ceoIcon} CEO`, selected: isCeoSelected, working: statuses['ceo'] === 'working' },
147
+ ...treeEntries.map(e => ({
148
+ text: e.line,
149
+ selected: e.roleId === flatRoles[selectedRoleIndex],
150
+ working: e.status === 'working',
151
+ })),
152
+ ];
153
+
154
+ // === Build right column: Stream/Info ===
155
+ const rightContentLines: string[] = [];
400
156
  if (rightTab === 'stream') {
401
- const maxEv = Math.min(termHeight - 8, 20);
402
- const visible = (selectedRoleId ? events.filter(e => e.roleId === selectedRoleId) : events).slice(-maxEv);
157
+ const maxEv = Math.max(5, termHeight - 10);
158
+ const filtered = selectedRoleId ? events.filter(e => e.roleId === selectedRoleId) : events;
159
+ const visible = filtered.slice(-maxEv);
403
160
  for (const ev of visible) {
404
- const line = eventToOneLiner(ev);
405
- if (line) rightLines.push(line.slice(0, rightWidth));
161
+ const line = eventLine(ev);
162
+ if (line) rightContentLines.push(line.slice(0, rightWidth));
163
+ }
164
+ if (rightContentLines.length === 0) {
165
+ rightContentLines.push(waveId ? `Waiting... (total ${events.length} events)` : 'No active stream. Type a directive to start.');
406
166
  }
407
- if (rightLines.length === 0) rightLines.push(waveId ? `Waiting... (${events.length} events, waveId=${waveId?.slice(-8)})` : 'No active stream.');
408
167
  } else if (rightTab === 'info') {
409
- rightLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
410
- if (wavePreset) rightLines.push(`Preset: ${wavePreset}`);
411
- rightLines.push(`Directive: ${focusedWave?.directive?.slice(0, 60) || '(idle)'}`);
412
- rightLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
413
- } else {
414
- rightLines.push('Docs tab (h/l to switch)');
168
+ rightContentLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
169
+ rightContentLines.push(`Directive: ${focusedWave?.directive?.slice(0, rightWidth - 12) || '(idle)'}`);
170
+ rightContentLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
171
+ rightContentLines.push(`Stream: ${streamStatus}`);
172
+ } else if (rightTab === 'docs') {
173
+ // Docs: scan .md files from COMPANY_ROOT
174
+ try {
175
+ const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude', '.obsidian']);
176
+ const mdFiles: string[] = [];
177
+ const walk = (dir: string, depth: number) => {
178
+ if (depth > 3 || mdFiles.length > 50) return;
179
+ try {
180
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
181
+ if (skip.has(e.name)) continue;
182
+ const full = path.join(dir, e.name);
183
+ if (e.isDirectory()) walk(full, depth + 1);
184
+ else if (e.name.endsWith('.md')) mdFiles.push(full.replace(companyRoot + '/', ''));
185
+ }
186
+ } catch {}
187
+ };
188
+ walk(companyRoot, 0);
189
+ mdFiles.sort();
190
+ const maxDocs = Math.max(5, termHeight - 12);
191
+ rightContentLines.push(`${mdFiles.length} documents`);
192
+ for (const f of mdFiles.slice(0, maxDocs)) {
193
+ rightContentLines.push(` ${f.slice(0, rightWidth - 4)}`);
194
+ }
195
+ if (mdFiles.length > maxDocs) rightContentLines.push(` ... +${mdFiles.length - maxDocs} more`);
196
+ } catch {
197
+ rightContentLines.push('Cannot scan documents');
198
+ }
415
199
  }
416
200
 
417
- // Merge into display lines
418
- const maxRows = Math.max(treeLines.length, rightLines.length);
419
- const mergedLines: Array<{ left: string; right: string }> = [];
201
+ // === Merge left + right, pad to fill terminal height ===
202
+ const headerLines = 3; // header + separator + org tree title
203
+ const footerLines = 2; // separator + keybindings
204
+ const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
205
+ const maxRows = Math.max(leftLines.length, rightContentLines.length, contentHeight);
206
+
207
+ const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
420
208
  for (let i = 0; i < maxRows; i++) {
421
- mergedLines.push({
422
- left: (treeLines[i] ?? '').padEnd(leftWidth).slice(0, leftWidth),
423
- right: (rightLines[i] ?? ''),
209
+ const ll = leftLines[i];
210
+ rows.push({
211
+ left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
212
+ right: rightContentLines[i] ?? '',
213
+ leftSelected: ll?.selected ?? false,
214
+ leftWorking: ll?.working ?? false,
424
215
  });
425
216
  }
426
217
 
427
218
  // Tab bar
428
- const tabLabels = ['Stream', 'Docs', 'Info'];
429
- const tabBar = tabLabels.map(t => t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `).join(' ');
219
+ const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
220
+ t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
221
+ ).join(' ');
430
222
 
431
223
  // Wave tabs
432
224
  const waveTabs = waves.length > 1
433
225
  ? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
434
226
  : '';
435
227
 
436
- // Separator line
437
- const sep = '\u2500'.repeat(termCols);
228
+ const sep = '\u2500'.repeat(Math.min(termCols, 160));
229
+ const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
438
230
 
439
231
  return (
440
232
  <Box flexDirection="column">
441
- {/* Header: wave title │ tabs */}
233
+ {/* Header */}
442
234
  <Text>
443
235
  <Text color="green" bold>{'W' + focusedWaveIndex}</Text>
444
236
  <Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
445
237
  <Text color="gray">{' \u2502 '}</Text>
446
238
  <Text color="cyan" bold>{tabBar}</Text>
447
- <Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '')}</Text>
239
+ <Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
240
+ <Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
448
241
  </Text>
449
242
  <Text color="gray">{sep}</Text>
450
243
 
451
- {/* Merged: OrgTree (left) │ Stream (right) */}
452
- {mergedLines.map((line, i) => (
244
+ {/* Content rows: left (OrgTree) │ right (Stream/Info) */}
245
+ {rows.map((row, i) => (
453
246
  <Text key={i}>
454
- <Text color={line.left.includes('\u25CF') ? 'green' : line.left.includes('\u2713') ? 'cyan' : 'white'}>{line.left}</Text>
247
+ <Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
455
248
  <Text color="gray">{' \u2502 '}</Text>
456
- <Text color="white">{line.right}</Text>
249
+ <Text color="white">{row.right}</Text>
457
250
  </Text>
458
251
  ))}
459
252
 
460
- {/* Separator + wave tabs + footer */}
253
+ {/* Footer */}
461
254
  <Text color="gray">{sep}</Text>
462
- {waveTabs ? (
463
- <Text>
464
- <Text color="gray">{waveTabs + ' '}</Text>
465
- <Text color="gray" dimColor>{'[h/l] tab [j/k] role [1-9] wave [Esc] back'}</Text>
466
- </Text>
467
- ) : (
468
- <Text color="gray" dimColor>{'[h/l] tab [j/k] role [Esc] back'}</Text>
469
- )}
255
+ <Text>
256
+ {waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
257
+ <Text color="gray" dimColor>{'[h/l] tab [j/k] role [Enter] filter '}</Text>
258
+ {waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
259
+ <Text color="gray" dimColor>{'[Esc] back'}</Text>
260
+ </Text>
470
261
  </Box>
471
262
  );
472
263
  };