tycono 0.3.14-beta.14 → 0.3.14-beta.16

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.14",
3
+ "version": "0.3.14-beta.16",
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,52 +668,9 @@ 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
- <Box flexDirection="column" height={termHeight}>
718
- <Box flexGrow={1} flexDirection="column">
672
+ <Box flexDirection="column">
673
+ <Box flexDirection="column">
719
674
  <PanelMode
720
675
  tree={orgTree}
721
676
  flatRoles={flatRoleIds}
@@ -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,120 +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]));
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]));
59
62
  }
60
- return lines;
63
+ return result;
61
64
  }
62
65
 
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 ?? ''}`;
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`;
74
81
  default: return null;
75
82
  }
76
83
  }
77
84
 
78
- function getWaveScopedStatuses(
79
- allSessions: SessionInfo[],
80
- focusedWaveId: string | null,
81
- ): Record<string, string> {
82
- if (!focusedWaveId) return {};
83
- const statuses: Record<string, string> = {};
84
- for (const s of allSessions) {
85
- if (s.waveId !== focusedWaveId) continue;
86
- if (s.status === 'active') statuses[s.roleId] = 'working';
87
- else if (!statuses[s.roleId]) statuses[s.roleId] = 'done';
88
- }
89
- return statuses;
90
- }
91
-
92
- function findSessionForRole(
93
- activeSessions: ActiveSessionInfo[],
94
- allSessions: SessionInfo[],
95
- roleId: string,
96
- focusedWaveId: string | null,
97
- ): ActiveSessionInfo | null {
98
- if (focusedWaveId) {
99
- const waveSes = allSessions.find(s => s.waveId === focusedWaveId && s.roleId === roleId && s.status === 'active');
100
- if (waveSes) return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
101
- }
102
- return activeSessions.find(s => s.roleId === roleId && s.status === 'active') ?? null;
103
- }
104
-
105
- function elapsed(startedAt: string): string {
106
- const ms = Date.now() - new Date(startedAt).getTime();
107
- if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
108
- if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m`;
109
- return `${Math.floor(ms / 3600_000)}h`;
110
- }
111
-
112
- /** Scan COMPANY_ROOT for .md files (cached) */
113
- let mdFileCache: { root: string; files: string[] } | null = null;
114
- function scanMdFiles(companyRoot: string): string[] {
115
- if (mdFileCache && mdFileCache.root === companyRoot) return mdFileCache.files;
116
- const results: string[] = [];
117
- const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude']);
118
- function walk(dir: string, depth: number) {
119
- if (depth > 3) return; // Don't go too deep
120
- try {
121
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
122
- if (skip.has(entry.name)) continue;
123
- const full = path.join(dir, entry.name);
124
- if (entry.isDirectory()) {
125
- walk(full, depth + 1);
126
- } else if (entry.name.endsWith('.md')) {
127
- results.push(full);
128
- }
129
- }
130
- } catch { /* permission error etc */ }
131
- }
132
- walk(companyRoot, 0);
133
- mdFileCache = { root: companyRoot, files: results };
134
- return results;
135
- }
136
-
137
- /** Extract files created/modified in this wave from SSE events */
138
- function extractWaveFiles(events: SSEEvent[]): string[] {
139
- const files = new Set<string>();
140
- for (const e of events) {
141
- if (e.type === 'tool:start') {
142
- const name = (e.data.name as string) ?? '';
143
- const input = e.data.input as Record<string, unknown> | undefined;
144
- if (['Write', 'Edit', 'NotebookEdit'].includes(name) && input?.file_path) {
145
- files.add(String(input.file_path));
146
- }
147
- }
148
- }
149
- return Array.from(files);
150
- }
151
-
152
- /** Read file preview (first N lines, cached) */
153
- const fileCache = new Map<string, string[]>();
154
- function readFilePreview(filePath: string, maxLines: number): string[] {
155
- const cached = fileCache.get(filePath);
156
- if (cached) return cached;
157
- try {
158
- const content = fs.readFileSync(filePath, 'utf-8');
159
- const lines = content.split('\n').slice(0, maxLines);
160
- fileCache.set(filePath, lines);
161
- // Evict old entries
162
- if (fileCache.size > 5) {
163
- const first = fileCache.keys().next().value;
164
- if (first) fileCache.delete(first);
165
- }
166
- return lines;
167
- } catch {
168
- return ['(cannot read file)'];
169
- }
170
- }
85
+ /* ─── Component ─── */
171
86
 
172
87
  const PanelModeInner: React.FC<PanelModeProps> = ({
173
88
  tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
@@ -176,295 +91,147 @@ const PanelModeInner: React.FC<PanelModeProps> = ({
176
91
  }) => {
177
92
  const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
178
93
  const [rightTab, setRightTab] = useState<RightTab>('stream');
179
- const [docsFilter, setDocsFilter] = useState<DocsFilter>('all');
180
- const [docsIndex, setDocsIndex] = useState(0);
181
- const [docsScroll, setDocsScroll] = useState(0);
182
94
 
183
95
  useEffect(() => {
184
- const onResize = () => setTermHeight(process.stdout.rows || 30);
185
- process.stdout.on('resize', onResize);
186
- 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); };
187
99
  }, []);
188
100
 
189
- // OOM fix: single separator character instead of repeated newlines
190
- // Previous: '│\n'.repeat(30) created 30 yoga nodes → layout explosion on large terminals
191
- const separatorStr = '\u2502';
192
-
193
- const waveScopedStatuses = useMemo(
194
- () => getWaveScopedStatuses(allSessions, focusedWaveId),
195
- [allSessions, focusedWaveId],
196
- );
197
-
198
- const waveScopedTree = useMemo(() => {
199
- function scopeNode(node: OrgNode): OrgNode {
200
- return {
201
- ...node,
202
- status: waveScopedStatuses[node.role.id] ?? 'idle',
203
- children: node.children.map(scopeNode),
204
- };
205
- }
206
- return tree.map(scopeNode);
207
- }, [tree, waveScopedStatuses]);
208
-
209
- // Wave files (from SSE events) — only compute when needed
210
- const waveFileSet = useMemo(() => {
211
- if (rightTab !== 'docs' && rightTab !== 'info') return new Set<string>();
212
- return new Set(extractWaveFiles(events));
213
- }, [rightTab === 'docs' || rightTab === 'info' ? events.length : 0, rightTab]);
214
-
215
- // Build docs list from filesystem scan + wave files
216
- const docsList = useMemo(() => {
217
- if (rightTab !== 'docs') return [];
218
-
219
- interface DocEntry { path: string; title: string; isWave: boolean; }
220
- const entries: DocEntry[] = [];
221
-
222
- // Scan all .md files from COMPANY_ROOT
223
- const allMdFiles = companyRoot ? scanMdFiles(companyRoot) : [];
224
-
225
- for (const filePath of allMdFiles) {
226
- const rel = filePath.replace(companyRoot + '/', '');
227
- const isWave = waveFileSet.has(filePath);
228
- const isKb = rel.startsWith('knowledge/');
229
- const isProject = rel.startsWith('projects/');
230
-
231
- if (docsFilter === 'wave' && !isWave) continue;
232
- if (docsFilter === 'kb' && !isKb) continue;
233
- if (docsFilter === 'projects' && !isProject) continue;
234
-
235
- entries.push({ path: filePath, title: rel, isWave });
236
- }
237
-
238
- // Wave-only files not already in list (e.g. code files written by agents)
239
- for (const f of waveFileSet) {
240
- if (!entries.some(e => e.path === f)) {
241
- if (docsFilter === 'kb' || docsFilter === 'projects') continue;
242
- entries.push({ path: f, title: f.split('/').pop() || f, isWave: true });
243
- }
244
- }
245
-
246
- // Sort: wave files first, then alphabetical
247
- entries.sort((a, b) => {
248
- if (a.isWave && !b.isWave) return -1;
249
- if (!a.isWave && b.isWave) return 1;
250
- return a.title.localeCompare(b.title);
251
- });
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]);
252
106
 
253
- return entries;
254
- }, [rightTab, docsFilter, companyRoot, waveFileSet]);
255
-
256
- const selectedDoc = docsList[docsIndex] ?? null;
257
- const filePreview = useMemo(() => {
258
- if (!selectedDoc || rightTab !== 'docs') return [];
259
- return readFilePreview(selectedDoc.path, 60);
260
- }, [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;
261
110
 
111
+ // Key handling
262
112
  useInput((input, key) => {
263
113
  if (key.escape) { onEscape(); return; }
264
-
265
- // h/l: switch right panel tab
266
- if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
114
+ if (input === 'h' || key.leftArrow) {
267
115
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
268
116
  const idx = tabs.indexOf(rightTab);
269
- if (idx > 0) { setRightTab(tabs[idx - 1]); setDocsScroll(0); }
117
+ if (idx > 0) setRightTab(tabs[idx - 1]);
270
118
  return;
271
119
  }
272
- if (input === 'l' || (key.rightArrow && rightTab !== 'info')) {
120
+ if (input === 'l' || key.rightArrow) {
273
121
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
274
122
  const idx = tabs.indexOf(rightTab);
275
- if (idx < tabs.length - 1) { setRightTab(tabs[idx + 1]); setDocsScroll(0); }
276
- return;
277
- }
278
-
279
- // j/k: context-dependent
280
- if (key.upArrow || input === 'k') {
281
- if (rightTab === 'docs') {
282
- if (docsScroll > 0) {
283
- setDocsScroll(s => Math.max(0, s - 3));
284
- } else {
285
- setDocsIndex(i => Math.max(0, i - 1));
286
- }
287
- } else if (rightTab === 'stream') {
288
- onMove('up');
289
- }
290
- return;
291
- }
292
- if (key.downArrow || input === 'j') {
293
- if (rightTab === 'docs') {
294
- if (docsScroll > 0) {
295
- setDocsScroll(s => s + 3);
296
- } else {
297
- setDocsIndex(i => Math.min(docsList.length - 1, i + 1));
298
- }
299
- } else if (rightTab === 'stream') {
300
- onMove('down');
301
- }
302
- return;
303
- }
304
-
305
- // Docs filter: 1-4
306
- if (rightTab === 'docs') {
307
- const filters: DocsFilter[] = ['all', 'wave', 'kb', 'projects'];
308
- const fi = parseInt(input, 10);
309
- if (fi >= 1 && fi <= 4) {
310
- setDocsFilter(filters[fi - 1]);
311
- setDocsIndex(0);
312
- setDocsScroll(0);
313
- return;
314
- }
315
- }
316
-
317
- // Tab key for cycling docs files
318
- if (key.tab && rightTab === 'docs') {
319
- setDocsIndex(i => (i + 1) % Math.max(1, docsList.length));
320
- setDocsScroll(0);
123
+ if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
321
124
  return;
322
125
  }
323
-
324
- // Enter
325
- if (key.return) {
326
- if (rightTab === 'docs' && selectedDoc) {
327
- const editor = process.env.EDITOR || 'vim';
328
- try {
329
- execSync(`${editor} "${selectedDoc.path}"`, { stdio: 'inherit' });
330
- } catch { /* user quit editor */ }
331
- fileCache.delete(selectedDoc.path); // Invalidate cache after edit
332
- return;
333
- }
334
- if (rightTab === 'stream') {
335
- onSelect();
336
- return;
337
- }
338
- }
339
-
340
- // 1-9: wave switch (only in stream/info tabs — docs uses 1-4 for filters)
341
- if (rightTab !== 'docs') {
342
- const num = parseInt(input, 10);
343
- if (num >= 1 && num <= 9 && num <= waves.length) {
344
- onFocusWave(waves[num - 1].waveId);
345
- }
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);
346
133
  }
347
134
  });
348
135
 
349
- // Filter events for selected role
350
- const roleEvents = selectedRoleId
351
- ? events.filter((e) => e.roleId === selectedRoleId)
352
- : events;
353
-
354
- const roleLabel = selectedRoleId
355
- ? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
356
- : 'All';
357
-
358
- const selectedSession = selectedRoleId
359
- ? findSessionForRole(activeSessions, allSessions, selectedRoleId, focusedWaveId)
360
- : null;
361
-
362
- const focusedWave = waves.find(w => w.waveId === focusedWaveId);
363
- const focusedWaveIndex = focusedWaveId
364
- ? waves.findIndex(w => w.waveId === focusedWaveId) + 1
365
- : 0;
366
-
367
- const waveSessionCount = focusedWaveId
368
- ? allSessions.filter(s => s.waveId === focusedWaveId).length
369
- : 0;
370
-
371
- // Read preset from wave file on disk
372
- const wavePreset = useMemo(() => {
373
- if (!focusedWaveId || !companyRoot) return null;
374
- try {
375
- const wavePath = path.join(companyRoot, 'operations', 'waves', `${focusedWaveId}.json`);
376
- if (fs.existsSync(wavePath)) {
377
- const data = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
378
- return data.preset as string | undefined;
379
- }
380
- } catch { /* ignore */ }
381
- return null;
382
- }, [focusedWaveId, companyRoot]);
383
-
384
136
  const leftWidth = 28;
385
137
  const termCols = process.stdout.columns || 120;
386
- const rightWidth = Math.max(40, termCols - leftWidth - 3);
387
-
388
- // === Build panel as line arrays, render each line as <Text> ===
389
- // yoga-layout OOMs with nested Box on 245+ columns.
390
- // Solution: flat list of <Text> elements (1 yoga node per line, no nesting)
391
-
392
- // Left: OrgTree
393
- const ceoIcon = waveScopedStatuses['ceo'] === 'working' ? '\u25CF' : waveScopedStatuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
394
- const treeLines = [`${ceoIcon} CEO`, ...flattenTreeForText(waveScopedTree)];
395
-
396
- // Right: Stream/Info content
397
- 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[] = [];
398
156
  if (rightTab === 'stream') {
399
- const maxEv = Math.min(termHeight - 8, 20);
400
- 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);
401
160
  for (const ev of visible) {
402
- const line = eventToOneLiner(ev);
403
- 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 for events...' : 'No active stream. Type a directive to start.');
404
166
  }
405
- if (rightLines.length === 0) rightLines.push(waveId ? `Waiting... (${events.length} events, waveId=${waveId?.slice(-8)})` : 'No active stream.');
406
167
  } else if (rightTab === 'info') {
407
- rightLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
408
- if (wavePreset) rightLines.push(`Preset: ${wavePreset}`);
409
- rightLines.push(`Directive: ${focusedWave?.directive?.slice(0, 60) || '(idle)'}`);
410
- rightLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
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}`);
411
171
  } else {
412
- rightLines.push('Docs tab (h/l to switch)');
172
+ rightContentLines.push('Press h to switch to Stream tab');
413
173
  }
414
174
 
415
- // Merge into display lines
416
- const maxRows = Math.max(treeLines.length, rightLines.length);
417
- const mergedLines: Array<{ left: string; right: string }> = [];
175
+ // === Merge left + right, pad to fill terminal height ===
176
+ const headerLines = 3; // header + separator + org tree title
177
+ const footerLines = 2; // separator + keybindings
178
+ const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
179
+ const maxRows = Math.max(leftLines.length, rightContentLines.length, contentHeight);
180
+
181
+ const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
418
182
  for (let i = 0; i < maxRows; i++) {
419
- mergedLines.push({
420
- left: (treeLines[i] ?? '').padEnd(leftWidth).slice(0, leftWidth),
421
- right: (rightLines[i] ?? ''),
183
+ const ll = leftLines[i];
184
+ rows.push({
185
+ left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
186
+ right: rightContentLines[i] ?? '',
187
+ leftSelected: ll?.selected ?? false,
188
+ leftWorking: ll?.working ?? false,
422
189
  });
423
190
  }
424
191
 
425
192
  // Tab bar
426
- const tabLabels = ['Stream', 'Docs', 'Info'];
427
- const tabBar = tabLabels.map(t => t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `).join(' ');
193
+ const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
194
+ t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
195
+ ).join(' ');
428
196
 
429
197
  // Wave tabs
430
198
  const waveTabs = waves.length > 1
431
199
  ? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
432
200
  : '';
433
201
 
434
- // Separator line
435
- const sep = '\u2500'.repeat(termCols);
202
+ const sep = '\u2500'.repeat(Math.min(termCols, 160));
203
+ const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
436
204
 
437
205
  return (
438
206
  <Box flexDirection="column">
439
- {/* Header: wave title │ tabs */}
207
+ {/* Header */}
440
208
  <Text>
441
209
  <Text color="green" bold>{'W' + focusedWaveIndex}</Text>
442
210
  <Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
443
211
  <Text color="gray">{' \u2502 '}</Text>
444
212
  <Text color="cyan" bold>{tabBar}</Text>
445
- <Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '')}</Text>
213
+ <Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
214
+ <Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
446
215
  </Text>
447
216
  <Text color="gray">{sep}</Text>
448
217
 
449
- {/* Merged: OrgTree (left) │ Stream (right) */}
450
- {mergedLines.map((line, i) => (
218
+ {/* Content rows: left (OrgTree) │ right (Stream/Info) */}
219
+ {rows.map((row, i) => (
451
220
  <Text key={i}>
452
- <Text color={line.left.includes('\u25CF') ? 'green' : line.left.includes('\u2713') ? 'cyan' : 'white'}>{line.left}</Text>
221
+ <Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
453
222
  <Text color="gray">{' \u2502 '}</Text>
454
- <Text color="white">{line.right}</Text>
223
+ <Text color="white">{row.right}</Text>
455
224
  </Text>
456
225
  ))}
457
226
 
458
- {/* Separator + wave tabs + footer */}
227
+ {/* Footer */}
459
228
  <Text color="gray">{sep}</Text>
460
- {waveTabs ? (
461
- <Text>
462
- <Text color="gray">{waveTabs + ' '}</Text>
463
- <Text color="gray" dimColor>{'[h/l] tab [j/k] role [1-9] wave [Esc] back'}</Text>
464
- </Text>
465
- ) : (
466
- <Text color="gray" dimColor>{'[h/l] tab [j/k] role [Esc] back'}</Text>
467
- )}
229
+ <Text>
230
+ {waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
231
+ <Text color="gray" dimColor>{'[h/l] tab [j/k] role [Enter] filter '}</Text>
232
+ {waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
233
+ <Text color="gray" dimColor>{'[Esc] back'}</Text>
234
+ </Text>
468
235
  </Box>
469
236
  );
470
237
  };