tycono 0.3.14-beta.9 → 0.3.14

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.9",
3
+ "version": "0.3.14",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,7 @@
35
35
  "cors": "^2.8.5",
36
36
  "dotenv": "^16.4.7",
37
37
  "express": "^5.0.1",
38
- "glob": "^11.0.1",
38
+ "glob": "^13.0.6",
39
39
  "gray-matter": "^4.0.3",
40
40
  "ink": "^5.2.1",
41
41
  "ink-select-input": "^6.2.0",
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';
@@ -632,6 +630,8 @@ export const App: React.FC = () => {
632
630
  return;
633
631
  }
634
632
  if (mode === 'command' && key.tab) {
633
+ // Clear terminal before Panel Mode (removes Command Mode scrollback)
634
+ process.stdout.write('\x1b[2J\x1b[H');
635
635
  setMode('panel');
636
636
  }
637
637
  });
@@ -670,52 +670,9 @@ export const App: React.FC = () => {
670
670
  // Command Mode: scrollable terminal (no fullscreen)
671
671
  // Panel Mode: fullscreen (intentional — like vim for inspection)
672
672
  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
673
  return (
717
674
  <Box flexDirection="column">
718
- <Box flexGrow={1} flexDirection="column">
675
+ <Box flexDirection="column">
719
676
  <PanelMode
720
677
  tree={orgTree}
721
678
  flatRoles={flatRoleIds}
@@ -83,16 +83,8 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
83
83
  }
84
84
 
85
85
  case 'thinking': {
86
- const text = ((event.data.text as string) ?? '').slice(0, 120);
87
- if (!text.trim()) return null;
88
- return {
89
- id: ++lineCounter,
90
- prefix: isSupervisor ? undefined : event.roleId,
91
- prefixColor: roleColor,
92
- text: `\uD83D\uDCAD ${text}`,
93
- color: 'gray',
94
- indent: !isSupervisor,
95
- };
86
+ // Hide thinking by default internal noise for user
87
+ return null;
96
88
  }
97
89
 
98
90
  case 'dispatch:start': {
@@ -130,17 +122,18 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
130
122
 
131
123
  case 'tool:start': {
132
124
  const toolName = (event.data.name as string) ?? 'tool';
125
+ // Only show Write/Edit (file changes) + Bash (commands). Hide Read/Grep/Glob (noise).
126
+ const isWrite = ['Write', 'Edit', 'NotebookEdit'].includes(toolName);
127
+ if (!isWrite) return null; // Only show file writes — hide Read/Grep/Glob/Bash
128
+
133
129
  const input = event.data.input;
134
130
  let detail = '';
135
131
  if (input && typeof input === 'object') {
136
132
  const inp = input as Record<string, unknown>;
137
- if (inp.file_path) detail = ` ${String(inp.file_path)}`;
138
- else if (inp.command) detail = ` ${String(inp.command).slice(0, 80)}`;
139
- else if (inp.pattern) detail = ` ${String(inp.pattern)}`;
140
- else if (inp.description) detail = ` ${String(inp.description).slice(0, 60)}`;
133
+ if (inp.file_path) detail = ` ${String(inp.file_path).split('/').slice(-2).join('/')}`;
134
+ else if (inp.command) detail = ` ${String(inp.command).slice(0, 60)}`;
135
+ else if (inp.description) detail = ` ${String(inp.description).slice(0, 40)}`;
141
136
  }
142
- // Highlight file writes
143
- const isWrite = ['Write', 'Edit'].includes(toolName);
144
137
  return {
145
138
  id: ++lineCounter,
146
139
  prefix: isSupervisor ? undefined : event.roleId,
@@ -152,15 +145,8 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
152
145
  }
153
146
 
154
147
  case 'tool:result': {
155
- const toolName = (event.data.name as string) ?? 'tool';
156
- return {
157
- id: ++lineCounter,
158
- prefix: isSupervisor ? undefined : event.roleId,
159
- prefixColor: roleColor,
160
- text: ` \u2190 ${toolName} done`,
161
- color: 'gray',
162
- indent: !isSupervisor,
163
- };
148
+ // Hide tool results tool:start is sufficient
149
+ return null;
164
150
  }
165
151
 
166
152
  case 'msg:start': {
@@ -421,10 +407,10 @@ export const CommandMode: React.FC<CommandModeProps> = ({
421
407
  const handleSubmit = useCallback((value: string) => {
422
408
  const trimmed = value.trim();
423
409
  if (trimmed) {
424
- // Show user input immediately in scrollback (before AI responds)
410
+ // Show user input with visual separator for emphasis
425
411
  setUserInputs(prev => [...prev.slice(-10), {
426
412
  id: ++lineCounter,
427
- text: `> ${trimmed}`,
413
+ text: `\u2501\u2501 > ${trimmed}`,
428
414
  color: 'green',
429
415
  }]);
430
416
  onSubmit(trimmed);
@@ -1,6 +1,6 @@
1
1
  /**
2
- * OrgTree — left panel showing organization hierarchy with real-time status
3
- * CEO is now selectable (index 0 in flatRoles)
2
+ * OrgTree — left panel showing organization hierarchy
3
+ * Simplified to single Text render to prevent yoga OOM on wide terminals
4
4
  */
5
5
 
6
6
  import React from 'react';
@@ -16,103 +16,36 @@ interface OrgTreeProps {
16
16
  ceoStatus?: string;
17
17
  }
18
18
 
19
- function statusColor(status: string): string {
20
- switch (status) {
21
- case 'working':
22
- case 'streaming':
23
- return 'green';
24
- case 'done':
25
- return 'gray';
26
- case 'error':
27
- return 'red';
28
- case 'awaiting_input':
29
- return 'yellow';
30
- default:
31
- return 'gray';
32
- }
33
- }
34
-
35
- interface FlatEntry {
36
- roleId: string;
37
- level: string;
38
- status: string;
39
- prefix: string;
40
- }
41
-
42
- function flattenTree(nodes: OrgNode[], prefix: string = '', isLast: boolean[] = []): FlatEntry[] {
43
- const result: FlatEntry[] = [];
44
-
19
+ function flattenTree(nodes: OrgNode[], isLast: boolean[] = []): Array<{ roleId: string; status: string; line: string }> {
20
+ const result: Array<{ roleId: string; status: string; line: string }> = [];
45
21
  for (let i = 0; i < nodes.length; i++) {
46
22
  const node = nodes[i];
47
23
  const last = i === nodes.length - 1;
48
-
49
- let linePrefix = '';
24
+ let prefix = '';
50
25
  for (let j = 0; j < isLast.length; j++) {
51
- linePrefix += isLast[j] ? ' ' : '\u2502 ';
26
+ prefix += isLast[j] ? ' ' : '\u2502 ';
52
27
  }
53
- linePrefix += isLast.length > 0 || i > 0 || nodes.length > 1
54
- ? (last ? '\u2514\u2500 ' : '\u251C\u2500 ')
55
- : '';
56
-
57
- result.push({
58
- roleId: node.role.id,
59
- level: node.role.level,
60
- status: node.status,
61
- prefix: linePrefix,
62
- });
63
-
28
+ prefix += last ? '\u2514\u2500 ' : '\u251C\u2500 ';
29
+ const icon = statusIcon(node.status);
30
+ result.push({ roleId: node.role.id, status: node.status, line: `${prefix}${icon} ${node.role.id}` });
64
31
  if (node.children.length > 0) {
65
- result.push(...flattenTree(node.children, '', [...isLast, last]));
32
+ result.push(...flattenTree(node.children, [...isLast, last]));
66
33
  }
67
34
  }
68
-
69
35
  return result;
70
36
  }
71
37
 
72
38
  export const OrgTree: React.FC<OrgTreeProps> = React.memo(({ tree, focused, selectedIndex, flatRoles, ceoStatus }) => {
73
- const entries = flattenTree(tree);
74
- const isCeoSelected = focused && flatRoles[selectedIndex] === 'ceo';
75
39
  const ceoIcon = statusIcon(ceoStatus ?? 'idle');
76
- const ceoColor = statusColor(ceoStatus ?? 'idle');
40
+ const entries = flattenTree(tree);
41
+
42
+ // Render entire tree as single Text block (1 yoga node instead of 50+)
43
+ const lines = [`${ceoIcon} CEO`, ...entries.map(e => e.line)];
77
44
 
78
45
  return (
79
46
  <Box flexDirection="column" paddingX={1}>
80
47
  <Text bold color={focused ? 'cyan' : 'gray'}>{'\u2500\u2500 Org Tree \u2500\u2500'}</Text>
81
- <Box marginTop={1}>
82
- <Text color={ceoColor} bold={ceoStatus === 'working'}>{ceoIcon} </Text>
83
- <Text
84
- color={isCeoSelected ? 'cyan' : 'yellow'}
85
- bold={isCeoSelected}
86
- inverse={isCeoSelected}
87
- >
88
- CEO
89
- </Text>
90
- </Box>
91
- {entries.map((entry, i) => {
92
- const isSelected = focused && flatRoles[selectedIndex] === entry.roleId;
93
- const icon = statusIcon(entry.status);
94
- const color = statusColor(entry.status);
95
-
96
- return (
97
- <Box key={entry.roleId + '-' + i}>
98
- <Text color="gray">{entry.prefix}</Text>
99
- <Text
100
- color={color}
101
- bold={entry.status === 'working'}
102
- >
103
- {icon}
104
- </Text>
105
- <Text> </Text>
106
- <Text
107
- color={isSelected ? 'cyan' : 'white'}
108
- bold={isSelected}
109
- inverse={isSelected}
110
- >
111
- {entry.roleId}
112
- </Text>
113
- </Box>
114
- );
115
- })}
48
+ <Text color="white">{'\n' + lines.join('\n')}</Text>
116
49
  </Box>
117
50
  );
118
51
  });
@@ -1,30 +1,21 @@
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';
12
+ import path from 'node:path';
18
13
  import { execSync } from 'node:child_process';
19
- import { OrgTree } from './OrgTree';
20
- import { StreamView } from './StreamView';
21
14
  import type { OrgNode } from '../store';
22
- import path from 'node:path';
23
15
  import type { SSEEvent, ActiveSessionInfo, SessionInfo } from '../api';
24
16
  import type { WaveInfo } from '../hooks/useCommand';
25
17
 
26
18
  type RightTab = 'stream' | 'docs' | 'info';
27
- type DocsFilter = 'all' | 'wave' | 'kb' | 'projects';
28
19
 
29
20
  interface PanelModeProps {
30
21
  tree: OrgNode[];
@@ -45,99 +36,61 @@ interface PanelModeProps {
45
36
  onFocusWave: (waveId: string) => void;
46
37
  }
47
38
 
48
- function getWaveScopedStatuses(
49
- allSessions: SessionInfo[],
50
- focusedWaveId: string | null,
51
- ): Record<string, string> {
52
- if (!focusedWaveId) return {};
53
- const statuses: Record<string, string> = {};
54
- for (const s of allSessions) {
55
- if (s.waveId !== focusedWaveId) continue;
56
- if (s.status === 'active') statuses[s.roleId] = 'working';
57
- else if (!statuses[s.roleId]) statuses[s.roleId] = 'done';
58
- }
59
- return statuses;
60
- }
39
+ /* ─── Helpers ─── */
61
40
 
62
- function findSessionForRole(
63
- activeSessions: ActiveSessionInfo[],
64
- allSessions: SessionInfo[],
65
- roleId: string,
66
- focusedWaveId: string | null,
67
- ): ActiveSessionInfo | null {
68
- if (focusedWaveId) {
69
- const waveSes = allSessions.find(s => s.waveId === focusedWaveId && s.roleId === roleId && s.status === 'active');
70
- if (waveSes) return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
41
+ function getWaveScopedStatuses(sessions: SessionInfo[], waveId: string | null): Record<string, string> {
42
+ if (!waveId) return {};
43
+ const s: Record<string, string> = {};
44
+ for (const ses of sessions) {
45
+ if (ses.waveId !== waveId) continue;
46
+ if (ses.status === 'active') s[ses.roleId] = 'working';
47
+ else if (!s[ses.roleId]) s[ses.roleId] = 'done';
71
48
  }
72
- return activeSessions.find(s => s.roleId === roleId && s.status === 'active') ?? null;
49
+ return s;
73
50
  }
74
51
 
75
- function elapsed(startedAt: string): string {
76
- const ms = Date.now() - new Date(startedAt).getTime();
77
- if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
78
- if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m`;
79
- return `${Math.floor(ms / 3600_000)}h`;
80
- }
81
-
82
- /** Scan COMPANY_ROOT for .md files (cached) */
83
- let mdFileCache: { root: string; files: string[] } | null = null;
84
- function scanMdFiles(companyRoot: string): string[] {
85
- if (mdFileCache && mdFileCache.root === companyRoot) return mdFileCache.files;
86
- const results: string[] = [];
87
- const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude']);
88
- function walk(dir: string, depth: number) {
89
- if (depth > 3) return; // Don't go too deep
90
- try {
91
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
92
- if (skip.has(entry.name)) continue;
93
- const full = path.join(dir, entry.name);
94
- if (entry.isDirectory()) {
95
- walk(full, depth + 1);
96
- } else if (entry.name.endsWith('.md')) {
97
- results.push(full);
98
- }
99
- }
100
- } catch { /* permission error etc */ }
52
+ function flattenTree(nodes: OrgNode[], isLast: boolean[] = []): Array<{ roleId: string; status: string; line: string }> {
53
+ const result: Array<{ roleId: string; status: string; line: string }> = [];
54
+ for (let i = 0; i < nodes.length; i++) {
55
+ const node = nodes[i];
56
+ const last = i === nodes.length - 1;
57
+ let prefix = '';
58
+ for (const l of isLast) prefix += l ? ' ' : '\u2502 ';
59
+ prefix += last ? '\u2514\u2500 ' : '\u251C\u2500 ';
60
+ const icon = node.status === 'working' ? '\u25CF' : node.status === 'done' ? '\u2713' : '\u25CB';
61
+ result.push({ roleId: node.role.id, status: node.status, line: `${prefix}${icon} ${node.role.id}` });
62
+ if (node.children.length > 0) result.push(...flattenTree(node.children, [...isLast, last]));
101
63
  }
102
- walk(companyRoot, 0);
103
- mdFileCache = { root: companyRoot, files: results };
104
- return results;
64
+ return result;
105
65
  }
106
66
 
107
- /** Extract files created/modified in this wave from SSE events */
108
- function extractWaveFiles(events: SSEEvent[]): string[] {
109
- const files = new Set<string>();
110
- for (const e of events) {
111
- if (e.type === 'tool:start') {
112
- const name = (e.data.name as string) ?? '';
113
- const input = e.data.input as Record<string, unknown> | undefined;
114
- if (['Write', 'Edit', 'NotebookEdit'].includes(name) && input?.file_path) {
115
- files.add(String(input.file_path));
116
- }
67
+ function eventLine(ev: SSEEvent): string | null {
68
+ let t: string;
69
+ try { t = new Date(ev.ts).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
70
+ catch { t = '--:--:--'; }
71
+ const r = (ev.roleId ?? '').padEnd(12);
72
+ switch (ev.type) {
73
+ case 'text': { const x = ((ev.data.text as string) ?? '').trim(); return x ? `${t} ${r} ${x}` : null; } // keep \n — split later
74
+ case 'thinking': return null; // Hide thinking — noise
75
+ case 'tool:start': {
76
+ const n = (ev.data.name as string) ?? '';
77
+ // Only show Write/Edit/Bash — hide Read/Grep/Glob (noise)
78
+ if (!['Write', 'Edit', 'NotebookEdit', 'Bash'].includes(n)) return null;
79
+ const inp = ev.data.input as Record<string, unknown> | undefined;
80
+ const d = inp ? ((inp.file_path as string)?.split('/').slice(-2).join('/') || (inp.command as string)?.slice(0, 50) || '').slice(0, 50) : '';
81
+ return `${t} ${r} ${n === 'Bash' ? '\u2192' : '\u{1F4C4}'} ${n} ${d}`;
117
82
  }
83
+ case 'tool:result': return null; // Hide — start is enough
84
+ case 'msg:start': return `${t} ${r} \u25B6 Started`;
85
+ case 'msg:done': { const turns = ev.data.turns as number | undefined; return `${t} ${r} \u2713 Done${turns ? ` (${turns} turns)` : ''}`; }
86
+ case 'msg:error': return `${t} ${r} \u2717 ${((ev.data.error ?? ev.data.message) as string ?? '').slice(0, 60)}`;
87
+ case 'dispatch:start': return `${t} ${r} \u21D2 dispatch ${ev.data.targetRole as string ?? ''}`;
88
+ case 'msg:awaiting_input': return `${t} ${r} ? awaiting input`;
89
+ default: return null;
118
90
  }
119
- return Array.from(files);
120
91
  }
121
92
 
122
- /** Read file preview (first N lines, cached) */
123
- const fileCache = new Map<string, string[]>();
124
- function readFilePreview(filePath: string, maxLines: number): string[] {
125
- const cached = fileCache.get(filePath);
126
- if (cached) return cached;
127
- try {
128
- const content = fs.readFileSync(filePath, 'utf-8');
129
- const lines = content.split('\n').slice(0, maxLines);
130
- fileCache.set(filePath, lines);
131
- // Evict old entries
132
- if (fileCache.size > 5) {
133
- const first = fileCache.keys().next().value;
134
- if (first) fileCache.delete(first);
135
- }
136
- return lines;
137
- } catch {
138
- return ['(cannot read file)'];
139
- }
140
- }
93
+ /* ─── Component ─── */
141
94
 
142
95
  const PanelModeInner: React.FC<PanelModeProps> = ({
143
96
  tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
@@ -146,420 +99,221 @@ const PanelModeInner: React.FC<PanelModeProps> = ({
146
99
  }) => {
147
100
  const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
148
101
  const [rightTab, setRightTab] = useState<RightTab>('stream');
149
- const [docsFilter, setDocsFilter] = useState<DocsFilter>('all');
150
102
  const [docsIndex, setDocsIndex] = useState(0);
151
- const [docsScroll, setDocsScroll] = useState(0);
152
103
 
153
104
  useEffect(() => {
154
- const onResize = () => setTermHeight(process.stdout.rows || 30);
155
- process.stdout.on('resize', onResize);
156
- return () => { process.stdout.off('resize', onResize); };
105
+ const fn = () => setTermHeight(process.stdout.rows || 30);
106
+ process.stdout.on('resize', fn);
107
+ return () => { process.stdout.off('resize', fn); };
157
108
  }, []);
158
109
 
159
- // OOM fix: single separator character instead of repeated newlines
160
- // Previous: '│\n'.repeat(30) created 30 yoga nodes → layout explosion on large terminals
161
- const separatorStr = '\u2502';
162
-
163
- const waveScopedStatuses = useMemo(
164
- () => getWaveScopedStatuses(allSessions, focusedWaveId),
165
- [allSessions, focusedWaveId],
166
- );
167
-
168
- const waveScopedTree = useMemo(() => {
169
- function scopeNode(node: OrgNode): OrgNode {
170
- return {
171
- ...node,
172
- status: waveScopedStatuses[node.role.id] ?? 'idle',
173
- children: node.children.map(scopeNode),
174
- };
175
- }
176
- return tree.map(scopeNode);
177
- }, [tree, waveScopedStatuses]);
178
-
179
- // Wave files (from SSE events) — only compute when needed
180
- const waveFileSet = useMemo(() => {
181
- if (rightTab !== 'docs' && rightTab !== 'info') return new Set<string>();
182
- return new Set(extractWaveFiles(events));
183
- }, [rightTab === 'docs' || rightTab === 'info' ? events.length : 0, rightTab]);
184
-
185
- // Build docs list from filesystem scan + wave files
186
- const docsList = useMemo(() => {
187
- if (rightTab !== 'docs') return [];
110
+ const statuses = useMemo(() => getWaveScopedStatuses(allSessions, focusedWaveId), [allSessions, focusedWaveId]);
111
+ const scopedTree = useMemo(() => {
112
+ const scope = (n: OrgNode): OrgNode => ({ ...n, status: statuses[n.role.id] ?? 'idle', children: n.children.map(scope) });
113
+ return tree.map(scope);
114
+ }, [tree, statuses]);
188
115
 
189
- interface DocEntry { path: string; title: string; isWave: boolean; }
190
- const entries: DocEntry[] = [];
191
-
192
- // Scan all .md files from COMPANY_ROOT
193
- const allMdFiles = companyRoot ? scanMdFiles(companyRoot) : [];
194
-
195
- for (const filePath of allMdFiles) {
196
- const rel = filePath.replace(companyRoot + '/', '');
197
- const isWave = waveFileSet.has(filePath);
198
- const isKb = rel.startsWith('knowledge/');
199
- const isProject = rel.startsWith('projects/');
200
-
201
- if (docsFilter === 'wave' && !isWave) continue;
202
- if (docsFilter === 'kb' && !isKb) continue;
203
- if (docsFilter === 'projects' && !isProject) continue;
204
-
205
- entries.push({ path: filePath, title: rel, isWave });
206
- }
207
-
208
- // Wave-only files not already in list (e.g. code files written by agents)
209
- for (const f of waveFileSet) {
210
- if (!entries.some(e => e.path === f)) {
211
- if (docsFilter === 'kb' || docsFilter === 'projects') continue;
212
- entries.push({ path: f, title: f.split('/').pop() || f, isWave: true });
213
- }
214
- }
215
-
216
- // Sort: wave files first, then alphabetical
217
- entries.sort((a, b) => {
218
- if (a.isWave && !b.isWave) return -1;
219
- if (!a.isWave && b.isWave) return 1;
220
- return a.title.localeCompare(b.title);
221
- });
222
-
223
- return entries;
224
- }, [rightTab, docsFilter, companyRoot, waveFileSet]);
225
-
226
- const selectedDoc = docsList[docsIndex] ?? null;
227
- const filePreview = useMemo(() => {
228
- if (!selectedDoc || rightTab !== 'docs') return [];
229
- return readFilePreview(selectedDoc.path, 60);
230
- }, [selectedDoc?.path, rightTab]);
116
+ const focusedWave = waves.find(w => w.waveId === focusedWaveId);
117
+ const focusedWaveIndex = focusedWaveId ? waves.findIndex(w => w.waveId === focusedWaveId) + 1 : 0;
118
+ const waveSessionCount = focusedWaveId ? allSessions.filter(s => s.waveId === focusedWaveId).length : 0;
231
119
 
120
+ // Key handling
232
121
  useInput((input, key) => {
233
122
  if (key.escape) { onEscape(); return; }
234
-
235
- // h/l: switch right panel tab
236
- if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
123
+ if (input === 'h' || key.leftArrow) {
237
124
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
238
125
  const idx = tabs.indexOf(rightTab);
239
- if (idx > 0) { setRightTab(tabs[idx - 1]); setDocsScroll(0); }
126
+ if (idx > 0) setRightTab(tabs[idx - 1]);
240
127
  return;
241
128
  }
242
- if (input === 'l' || (key.rightArrow && rightTab !== 'info')) {
129
+ if (input === 'l' || key.rightArrow) {
243
130
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
244
131
  const idx = tabs.indexOf(rightTab);
245
- if (idx < tabs.length - 1) { setRightTab(tabs[idx + 1]); setDocsScroll(0); }
132
+ if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
246
133
  return;
247
134
  }
248
-
249
- // j/k: context-dependent
250
- if (key.upArrow || input === 'k') {
251
- if (rightTab === 'docs') {
252
- if (docsScroll > 0) {
253
- setDocsScroll(s => Math.max(0, s - 3));
254
- } else {
255
- setDocsIndex(i => Math.max(0, i - 1));
256
- }
257
- } else if (rightTab === 'stream') {
258
- onMove('up');
259
- }
135
+ // j/k context-dependent
136
+ if (input === 'k' || key.upArrow) {
137
+ if (rightTab === 'docs') { setDocsIndex(i => Math.max(0, i - 1)); }
138
+ else { onMove('up'); }
260
139
  return;
261
140
  }
262
- if (key.downArrow || input === 'j') {
263
- if (rightTab === 'docs') {
264
- if (docsScroll > 0) {
265
- setDocsScroll(s => s + 3);
266
- } else {
267
- setDocsIndex(i => Math.min(docsList.length - 1, i + 1));
268
- }
269
- } else if (rightTab === 'stream') {
270
- onMove('down');
271
- }
141
+ if (input === 'j' || key.downArrow) {
142
+ if (rightTab === 'docs') { setDocsIndex(i => i + 1); } // capped later by docsList length
143
+ else { onMove('down'); }
272
144
  return;
273
145
  }
274
-
275
- // Docs filter: 1-4
276
- if (rightTab === 'docs') {
277
- const filters: DocsFilter[] = ['all', 'wave', 'kb', 'projects'];
278
- const fi = parseInt(input, 10);
279
- if (fi >= 1 && fi <= 4) {
280
- setDocsFilter(filters[fi - 1]);
281
- setDocsIndex(0);
282
- setDocsScroll(0);
283
- return;
284
- }
285
- }
286
-
287
- // Tab key for cycling docs files
288
- if (key.tab && rightTab === 'docs') {
289
- setDocsIndex(i => (i + 1) % Math.max(1, docsList.length));
290
- setDocsScroll(0);
291
- return;
292
- }
293
-
294
- // Enter
295
146
  if (key.return) {
296
- if (rightTab === 'docs' && selectedDoc) {
297
- const editor = process.env.EDITOR || 'vim';
147
+ if (rightTab === 'docs' && selectedDocPath) {
298
148
  try {
299
- execSync(`${editor} "${selectedDoc.path}"`, { stdio: 'inherit' });
300
- } catch { /* user quit editor */ }
301
- fileCache.delete(selectedDoc.path); // Invalidate cache after edit
302
- return;
303
- }
304
- if (rightTab === 'stream') {
149
+ const editor = process.env.EDITOR || 'vim';
150
+ execSync(`${editor} "${selectedDocPath}"`, { stdio: 'inherit' });
151
+ } catch { /* ignore */ }
152
+ } else {
305
153
  onSelect();
306
- return;
307
154
  }
155
+ return;
308
156
  }
309
-
310
- // 1-9: wave switch (only in stream/info tabs — docs uses 1-4 for filters)
311
- if (rightTab !== 'docs') {
312
- const num = parseInt(input, 10);
313
- if (num >= 1 && num <= 9 && num <= waves.length) {
314
- onFocusWave(waves[num - 1].waveId);
315
- }
157
+ // Wave switch 1-9
158
+ const num = parseInt(input, 10);
159
+ if (num >= 1 && num <= 9 && num <= waves.length) {
160
+ onFocusWave(waves[num - 1].waveId);
316
161
  }
317
162
  });
318
163
 
319
- // Filter events for selected role
320
- const roleEvents = selectedRoleId
321
- ? events.filter((e) => e.roleId === selectedRoleId)
322
- : events;
323
-
324
- const roleLabel = selectedRoleId
325
- ? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
326
- : 'All';
327
-
328
- const selectedSession = selectedRoleId
329
- ? findSessionForRole(activeSessions, allSessions, selectedRoleId, focusedWaveId)
330
- : null;
331
-
332
- const focusedWave = waves.find(w => w.waveId === focusedWaveId);
333
- const focusedWaveIndex = focusedWaveId
334
- ? waves.findIndex(w => w.waveId === focusedWaveId) + 1
335
- : 0;
336
-
337
- const waveSessionCount = focusedWaveId
338
- ? allSessions.filter(s => s.waveId === focusedWaveId).length
339
- : 0;
340
-
341
- // Read preset from wave file on disk
342
- const wavePreset = useMemo(() => {
343
- if (!focusedWaveId || !companyRoot) return null;
164
+ const leftWidth = 28;
165
+ const termCols = process.stdout.columns || 120;
166
+ const rightWidth = termCols - leftWidth - 3;
167
+ const headerLines = 2;
168
+ const footerLines = 3;
169
+ const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
170
+
171
+ // === Build left column: OrgTree ===
172
+ const ceoIcon = statuses['ceo'] === 'working' ? '\u25CF' : statuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
173
+ const isCeoSelected = flatRoles[selectedRoleIndex] === 'ceo';
174
+ const treeEntries = flattenTree(scopedTree);
175
+
176
+ const leftLines: Array<{ text: string; selected: boolean; working: boolean }> = [
177
+ { text: `${ceoIcon} CEO`, selected: isCeoSelected, working: statuses['ceo'] === 'working' },
178
+ ...treeEntries.map(e => ({
179
+ text: e.line,
180
+ selected: e.roleId === flatRoles[selectedRoleIndex],
181
+ working: e.status === 'working',
182
+ })),
183
+ ];
184
+
185
+ // Derive selectedRoleId from index (more reliable than prop — avoids sync issues)
186
+ const activeRoleId = flatRoles[selectedRoleIndex] ?? null;
187
+
188
+ // === Build right column: Stream/Info/Docs ===
189
+ const rightContentLines: string[] = [];
190
+ let selectedDocPath: string | null = null;
191
+ if (rightTab === 'stream') {
192
+ if (activeRoleId) rightContentLines.push(`\u25B8 ${activeRoleId}`);
193
+ const maxEv = Math.max(5, contentHeight - 3);
194
+ const filtered = activeRoleId ? events.filter(e => e.roleId === activeRoleId) : events;
195
+ const visible = filtered.slice(-maxEv);
196
+ for (const ev of visible) {
197
+ const line = eventLine(ev);
198
+ if (!line) continue;
199
+ // Split multi-line text events into separate lines (preserves markdown)
200
+ const sublines = line.split('\n');
201
+ for (const sl of sublines) {
202
+ if (rightContentLines.length >= maxEv) break;
203
+ rightContentLines.push(sl.slice(0, rightWidth));
204
+ }
205
+ }
206
+ if (rightContentLines.length === 0) {
207
+ if (activeRoleId && events.length > 0) {
208
+ rightContentLines.push(`No events for ${activeRoleId} (${events.length} total)`);
209
+ rightContentLines.push('Press Enter to show all roles');
210
+ } else {
211
+ rightContentLines.push(waveId ? `Waiting for events... (${events.length} in buffer)` : 'No active stream. Type a directive to start.');
212
+ }
213
+ }
214
+ } else if (rightTab === 'info') {
215
+ rightContentLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
216
+ rightContentLines.push(`Directive: ${focusedWave?.directive?.slice(0, rightWidth - 12) || '(idle)'}`);
217
+ rightContentLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
218
+ rightContentLines.push(`Stream: ${streamStatus}`);
219
+ } else if (rightTab === 'docs') {
220
+ // Docs: scan .md files with j/k scroll + Enter to open
344
221
  try {
345
- const wavePath = path.join(companyRoot, 'operations', 'waves', `${focusedWaveId}.json`);
346
- if (fs.existsSync(wavePath)) {
347
- const data = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
348
- return data.preset as string | undefined;
222
+ const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude', '.obsidian']);
223
+ const mdFiles: string[] = [];
224
+ const mdPaths: string[] = []; // full paths for vim
225
+ const walk = (dir: string, depth: number) => {
226
+ if (depth > 3 || mdFiles.length > 200) return;
227
+ try {
228
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
229
+ if (skip.has(e.name)) continue;
230
+ const full = path.join(dir, e.name);
231
+ if (e.isDirectory()) walk(full, depth + 1);
232
+ else if (e.name.endsWith('.md')) {
233
+ mdFiles.push(full.replace(companyRoot + '/', ''));
234
+ mdPaths.push(full);
235
+ }
236
+ }
237
+ } catch {}
238
+ };
239
+ walk(companyRoot, 0);
240
+ mdFiles.sort();
241
+ mdPaths.sort();
242
+ // Cap docsIndex
243
+ const cappedIdx = Math.min(docsIndex, mdFiles.length - 1);
244
+ if (cappedIdx !== docsIndex) setDocsIndex(Math.max(0, cappedIdx));
245
+ selectedDocPath = mdPaths[cappedIdx] ?? null;
246
+
247
+ const maxVisible = Math.max(5, termHeight - 12);
248
+ const scrollStart = Math.max(0, Math.min(cappedIdx - 3, mdFiles.length - maxVisible));
249
+ rightContentLines.push(`${mdFiles.length} documents [j/k] browse [Enter] ${process.env.EDITOR || 'vim'}`);
250
+ for (let i = scrollStart; i < Math.min(scrollStart + maxVisible, mdFiles.length); i++) {
251
+ const selected = i === cappedIdx;
252
+ const prefix = selected ? '\u25B6 ' : ' ';
253
+ rightContentLines.push(`${prefix}${mdFiles[i].slice(0, rightWidth - 4)}`);
349
254
  }
350
- } catch { /* ignore */ }
351
- return null;
352
- }, [focusedWaveId, companyRoot]);
353
-
354
- const leftWidth = 28;
355
-
356
- return (
357
- <Box flexDirection="column" flexGrow={1}>
358
- <Box flexGrow={1}>
359
- {/* Left: Wave title + Org Tree + Wave tabs */}
360
- <Box flexDirection="column" width={leftWidth}>
361
- <Box paddingX={1}>
362
- <Text color="green" bold>W{focusedWaveIndex}</Text>
363
- {wavePreset && wavePreset !== 'default' && (
364
- <Text color="magenta"> ({wavePreset})</Text>
365
- )}
366
- <Text color="gray"> </Text>
367
- <Text color="white" wrap="truncate">
368
- {focusedWave?.directive ? focusedWave.directive.slice(0, leftWidth - 6) : '(idle)'}
369
- </Text>
370
- </Box>
371
- {waveSessionCount > 0 && (
372
- <Box paddingX={1}>
373
- <Text color="gray">{waveSessionCount} sessions</Text>
374
- </Box>
375
- )}
376
-
377
- <OrgTree
378
- tree={waveScopedTree}
379
- focused={rightTab === 'stream'}
380
- selectedIndex={selectedRoleIndex}
381
- flatRoles={flatRoles}
382
- ceoStatus={waveScopedStatuses['ceo'] ?? 'idle'}
383
- />
384
-
385
- {waves.length > 1 && (
386
- <Box paddingX={1} marginTop={1}>
387
- {waves.map((w, i) => (
388
- <Box key={w.waveId} marginRight={1}>
389
- <Text
390
- color={w.waveId === focusedWaveId ? 'green' : 'gray'}
391
- bold={w.waveId === focusedWaveId}
392
- inverse={w.waveId === focusedWaveId}
393
- >{` ${i + 1} `}</Text>
394
- </Box>
395
- ))}
396
- </Box>
397
- )}
398
- </Box>
399
-
400
- {/* Vertical separator — single character, not repeated newlines */}
401
- <Text color="gray">{separatorStr}</Text>
402
-
403
- {/* Right: Tabbed panel */}
404
- <Box flexGrow={1} flexDirection="column">
405
- {/* Tab bar */}
406
- <Box paddingX={1} marginBottom={0}>
407
- {(['stream', 'docs', 'info'] as RightTab[]).map(tab => (
408
- <Box key={tab} marginRight={1}>
409
- <Text
410
- color={rightTab === tab ? 'cyan' : 'gray'}
411
- bold={rightTab === tab}
412
- inverse={rightTab === tab}
413
- >
414
- {` ${tab.charAt(0).toUpperCase() + tab.slice(1)} `}
415
- </Text>
416
- </Box>
417
- ))}
418
- <Text color="gray" dimColor> [h/l] switch</Text>
419
- </Box>
420
-
421
- {/* Stream tab */}
422
- {rightTab === 'stream' && (
423
- <>
424
- {selectedRoleId && selectedSession && (
425
- <Box flexDirection="column" paddingX={1}>
426
- <Box justifyContent="space-between">
427
- <Text bold color="cyan">{selectedRoleId}</Text>
428
- <Text color={selectedSession.status === 'active' ? 'green' : 'gray'}>
429
- {selectedSession.status === 'active' ? '\u25CF' : '\u25CB'} {selectedSession.status}
430
- {selectedSession.startedAt ? ` (${elapsed(selectedSession.startedAt)})` : ''}
431
- </Text>
432
- </Box>
433
- {selectedSession.ports.api > 0 && (
434
- <Text color="gray">Port API:{selectedSession.ports.api} Vite:{selectedSession.ports.vite}</Text>
435
- )}
436
- <Text color="gray">{'\u2500'.repeat(40)}</Text>
437
- </Box>
438
- )}
439
- {selectedRoleId && !selectedSession && (
440
- <Box flexDirection="column" paddingX={1}>
441
- <Text bold color="cyan">{selectedRoleId}</Text>
442
- <Text color="gray">(not active in this wave)</Text>
443
- <Text color="gray">{'\u2500'.repeat(40)}</Text>
444
- </Box>
445
- )}
446
- <StreamView
447
- events={roleEvents}
448
- allRoleIds={flatRoles}
449
- streamStatus={streamStatus}
450
- waveId={waveId}
451
- roleLabel={roleLabel}
452
- />
453
- </>
454
- )}
255
+ } catch {
256
+ rightContentLines.push('Cannot scan documents');
257
+ }
258
+ }
455
259
 
456
- {/* Docs tab KB browser + wave artifacts */}
457
- {rightTab === 'docs' && (
458
- <Box flexDirection="column" paddingX={1} flexGrow={1}>
459
- {/* Filter bar */}
460
- <Box marginBottom={0}>
461
- {(['all', 'wave', 'kb', 'projects'] as DocsFilter[]).map((f, i) => (
462
- <Box key={f} marginRight={1}>
463
- <Text
464
- color={docsFilter === f ? 'cyan' : 'gray'}
465
- bold={docsFilter === f}
466
- inverse={docsFilter === f}
467
- >
468
- {f === 'wave' ? ` ${i + 1}:\u2605Wave ` : ` ${i + 1}:${f.charAt(0).toUpperCase() + f.slice(1)} `}
469
- </Text>
470
- </Box>
471
- ))}
472
- <Text color="gray" dimColor> ({docsList.length})</Text>
473
- </Box>
260
+ // === Merge left + right, cap to terminal height ===
261
+ const maxRows = contentHeight;
262
+
263
+ const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
264
+ for (let i = 0; i < maxRows; i++) {
265
+ const ll = leftLines[i];
266
+ rows.push({
267
+ left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
268
+ right: rightContentLines[i] ?? '',
269
+ leftSelected: ll?.selected ?? false,
270
+ leftWorking: ll?.working ?? false,
271
+ });
272
+ }
474
273
 
475
- {docsList.length === 0 ? (
476
- <Box marginTop={1}>
477
- <Text color="gray">{docsFilter === 'wave' ? 'No files created in this wave.' : 'No documents found.'}</Text>
478
- </Box>
479
- ) : (
480
- <Box flexGrow={1} flexDirection="column">
481
- {docsScroll === 0 ? (
482
- /* File list — only render visible window (prevent Yoga OOM on 600+ files) */
483
- <Box flexDirection="column" marginTop={0}>
484
- <Text color="gray" dimColor>{docsList.length} files{docsIndex > 0 ? ` (${docsIndex + 1}/${docsList.length})` : ''}</Text>
485
- {docsList.slice(docsIndex, docsIndex + termHeight - 10).map((doc, i) => (
486
- <Box key={doc.path}>
487
- <Text
488
- color={i === 0 ? 'cyan' : doc.isWave ? 'green' : 'white'}
489
- bold={i === 0}
490
- inverse={i === 0}
491
- >
492
- {doc.isWave ? '\u2605' : ' '} {doc.title.slice(0, 55)}
493
- </Text>
494
- </Box>
495
- ))}
496
- </Box>
497
- ) : (
498
- /* File preview */
499
- <Box flexDirection="column">
500
- <Text color="cyan" bold>{selectedDoc?.isWave ? '\u2605 ' : ''}{selectedDoc?.path.split('/').slice(-2).join('/')}</Text>
501
- <Text color="gray">{'\u2500'.repeat(50)}</Text>
502
- {filePreview.slice(docsScroll - 1, docsScroll - 1 + termHeight - 10).map((line, i) => (
503
- <Text key={i} color="white" wrap="wrap">{line}</Text>
504
- ))}
505
- </Box>
506
- )}
274
+ // Tab bar
275
+ const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
276
+ t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
277
+ ).join(' ');
507
278
 
508
- <Box marginTop={0}>
509
- <Text color="gray" dimColor>
510
- [Enter] {process.env.EDITOR || 'vim'} | [j/k] {docsScroll > 0 ? 'scroll' : 'select'}
511
- </Text>
512
- </Box>
513
- </Box>
514
- )}
515
- </Box>
516
- )}
279
+ // Wave tabs
280
+ const waveTabs = waves.length > 1
281
+ ? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
282
+ : '';
517
283
 
518
- {/* Info tab */}
519
- {rightTab === 'info' && (
520
- <Box flexDirection="column" paddingX={1}>
521
- <Text bold color="cyan">Wave Info</Text>
522
- <Text color="gray">{'\u2500'.repeat(40)}</Text>
523
- <Text color="white">Wave: {focusedWave?.waveId ?? 'none'}</Text>
524
- {wavePreset && <Text color="magenta">Preset: {wavePreset}</Text>}
525
- <Text color="white">Directive: {focusedWave?.directive || '(idle)'}</Text>
526
- <Text color="white">Sessions: {waveSessionCount}</Text>
527
- <Text color="white">Files modified: {waveFileSet.size}</Text>
528
- <Text color="white">SSE events: {events.length}</Text>
284
+ const sep = '\u2500'.repeat(Math.min(termCols, 160));
285
+ const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
529
286
 
530
- {/* Active sessions in this wave */}
531
- {waveSessionCount > 0 && (
532
- <>
533
- <Text color="gray" bold>{'\n'}Active in this wave:</Text>
534
- {allSessions
535
- .filter(s => s.waveId === focusedWaveId && s.status === 'active')
536
- .slice(0, 10)
537
- .map(s => {
538
- const port = activeSessions.find(a => a.sessionId === s.id);
539
- return (
540
- <Text key={s.id} color="white">
541
- {` ${s.roleId.padEnd(12)} ${s.id.slice(0, 20)} ${port ? `API:${port.ports.api}` : ''}`}
542
- </Text>
543
- );
544
- })
545
- }
546
- </>
547
- )}
548
- </Box>
549
- )}
550
- </Box>
551
- </Box>
287
+ return (
288
+ <Box flexDirection="column">
289
+ {/* Header */}
290
+ <Text>
291
+ <Text color="green" bold>{'W' + focusedWaveIndex}</Text>
292
+ <Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
293
+ <Text color="gray">{' \u2502 '}</Text>
294
+ <Text color="cyan" bold>{tabBar}</Text>
295
+ <Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
296
+ <Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
297
+ </Text>
298
+ <Text color="gray">{sep}</Text>
299
+
300
+ {/* Content rows: left (OrgTree) │ right (Stream/Info) */}
301
+ {rows.map((row, i) => (
302
+ <Text key={i}>
303
+ <Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
304
+ <Text color="gray">{' \u2502 '}</Text>
305
+ <Text color="white">{row.right}</Text>
306
+ </Text>
307
+ ))}
552
308
 
553
309
  {/* Footer */}
554
- <Box width="100%">
555
- <Text color="gray">{'\u2500'.repeat(process.stdout.columns || 70)}</Text>
556
- </Box>
557
- <Box paddingX={1} justifyContent="center">
558
- <Text color="gray" dimColor>
559
- [h/l] tab [j/k] {rightTab === 'stream' ? 'role' : 'scroll'} {rightTab === 'docs' ? '[Enter] vim ' : ''}
560
- {waves.length > 1 ? '[1-9] wave ' : ''}[Esc] command
561
- </Text>
562
- </Box>
310
+ <Text color="gray">{sep}</Text>
311
+ <Text>
312
+ {waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
313
+ <Text color="gray" dimColor>{rightTab === 'docs' ? '[h/l] tab [j/k] browse [Enter] open ' : '[h/l] tab [j/k] role [Enter] filter '}</Text>
314
+ {waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
315
+ <Text color="gray" dimColor>{'[Esc] back'}</Text>
316
+ </Text>
563
317
  </Box>
564
318
  );
565
319
  };
@@ -1,13 +1,13 @@
1
1
  /**
2
- * StreamView — detailed stream panel for Panel Mode (right side)
3
- * Shows full event details with timestamps for a selected role.
4
- * No aggressive truncation shows tools, thinking, dispatch like Claude Code.
2
+ * StreamView — stream panel for Panel Mode
3
+ * Simplified to single Text render to prevent yoga OOM on wide terminals.
4
+ * Previous: 30 events × 3 React elements = 90 yoga nodes → OOM on 245+ columns
5
+ * Now: 1 Text element with pre-formatted string
5
6
  */
6
7
 
7
8
  import React from 'react';
8
9
  import { Box, Text } from 'ink';
9
10
  import type { SSEEvent } from '../api';
10
- import { getRoleColor } from '../theme';
11
11
 
12
12
  interface StreamViewProps {
13
13
  events: SSEEvent[];
@@ -21,111 +21,58 @@ function formatTime(ts: string): string {
21
21
  try {
22
22
  const d = new Date(ts);
23
23
  return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
24
- } catch {
25
- return '--:--:--';
26
- }
24
+ } catch { return '--:--:--'; }
27
25
  }
28
26
 
29
- function renderEvent(event: SSEEvent): { content: string; contentColor: string } | null {
30
- switch (event.type) {
31
- case 'msg:start':
32
- return {
33
- content: `\u25B6 Started: ${(event.data.task as string)?.replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80) ?? ''}`,
34
- contentColor: 'green',
35
- };
27
+ function eventToLine(event: SSEEvent): string | null {
28
+ const time = formatTime(event.ts);
29
+ const role = event.roleId.padEnd(12);
36
30
 
31
+ switch (event.type) {
32
+ case 'msg:start': {
33
+ const task = ((event.data.task as string) ?? '').replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80);
34
+ return `${time} ${role} \u25B6 Started: ${task}`;
35
+ }
37
36
  case 'msg:done': {
38
37
  const turns = event.data.turns as number | undefined;
39
- return {
40
- content: `\u2713 Done${turns ? ` (${turns} turns)` : ''}`,
41
- contentColor: 'green',
42
- };
38
+ return `${time} ${role} \u2713 Done${turns ? ` (${turns} turns)` : ''}`;
43
39
  }
44
-
45
40
  case 'msg:error':
46
- return {
47
- content: `\u2717 Error: ${(event.data.error as string ?? event.data.message as string ?? '').slice(0, 120)}`,
48
- contentColor: 'red',
49
- };
50
-
41
+ return `${time} ${role} \u2717 ${((event.data.error ?? event.data.message) as string ?? '').slice(0, 80)}`;
51
42
  case 'text': {
52
- const text = ((event.data.text as string) ?? '');
53
- if (!text.trim()) return null;
54
- // Don't truncate let terminal wrap
55
- return { content: text, contentColor: 'white' };
43
+ const text = ((event.data.text as string) ?? '').trim();
44
+ if (!text) return null;
45
+ return `${time} ${role} ${text.slice(0, 120)}`;
56
46
  }
57
-
58
47
  case 'thinking': {
59
- const text = ((event.data.text as string) ?? '').slice(0, 150);
60
- if (!text.trim()) return null;
61
- return { content: `\uD83D\uDCAD ${text}`, contentColor: 'gray' };
48
+ const text = ((event.data.text as string) ?? '').trim().slice(0, 100);
49
+ if (!text) return null;
50
+ return `${time} ${role} \uD83D\uDCAD ${text}`;
62
51
  }
63
-
64
52
  case 'tool:start': {
65
53
  const name = (event.data.name as string) ?? 'tool';
66
- const input = event.data.input;
54
+ const input = event.data.input as Record<string, unknown> | undefined;
67
55
  let detail = '';
68
- if (input && typeof input === 'object') {
69
- const inp = input as Record<string, unknown>;
70
- if (inp.file_path) detail = ` ${String(inp.file_path)}`;
71
- else if (inp.command) detail = ` ${String(inp.command).slice(0, 80)}`;
72
- else if (inp.pattern) detail = ` ${String(inp.pattern)}`;
73
- else detail = ` ${JSON.stringify(input).slice(0, 80)}`;
56
+ if (input) {
57
+ if (input.file_path) detail = ` ${String(input.file_path).slice(0, 60)}`;
58
+ else if (input.command) detail = ` ${String(input.command).slice(0, 60)}`;
59
+ else if (input.pattern) detail = ` ${String(input.pattern)}`;
74
60
  }
75
- return {
76
- content: `\u2192 ${name}${detail}`,
77
- contentColor: 'gray',
78
- };
61
+ return `${time} ${role} \u2192 ${name}${detail}`;
79
62
  }
80
-
81
- case 'tool:result':
82
- return {
83
- content: `\u2190 ${(event.data.name as string) ?? 'tool'} done`,
84
- contentColor: 'gray',
85
- };
86
-
87
63
  case 'dispatch:start':
88
- return {
89
- content: `\u21D2 dispatch ${event.data.targetRole as string ?? ''}: ${(event.data.task as string)?.replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80) ?? ''}`,
90
- contentColor: 'yellow',
91
- };
92
-
93
- case 'dispatch:done':
94
- return {
95
- content: `\u21D0 ${event.data.targetRole as string ?? ''} completed`,
96
- contentColor: 'yellow',
97
- };
98
-
99
- case 'msg:awaiting_input': {
100
- const question = (event.data.question as string) ?? '';
101
- return {
102
- content: question ? `? ${question.slice(0, 120)}` : '? Awaiting input...',
103
- contentColor: 'yellow',
104
- };
105
- }
106
-
107
- // Hidden (truly internal only)
108
- case 'heartbeat:tick':
109
- case 'heartbeat:skip':
110
- case 'prompt:assembled':
111
- case 'trace:response':
112
- return null;
113
-
64
+ return `${time} ${role} \u21D2 dispatch ${event.data.targetRole as string ?? ''}`;
114
65
  default:
115
66
  return null;
116
67
  }
117
68
  }
118
69
 
119
70
  const StreamViewInner: React.FC<StreamViewProps> = ({
120
- events,
121
- allRoleIds,
122
- streamStatus,
123
- waveId,
124
- roleLabel,
71
+ events, allRoleIds, streamStatus, waveId, roleLabel,
125
72
  }) => {
126
- const maxVisible = 30;
73
+ const termRows = process.stdout.rows || 40;
74
+ const maxVisible = Math.min(Math.max(5, termRows - 15), 20);
127
75
  const visibleEvents = events.slice(-maxVisible);
128
-
129
76
  const turnCount = events.filter(e => e.type === 'text' || e.type === 'tool:start').length;
130
77
 
131
78
  const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming'
@@ -133,39 +80,24 @@ const StreamViewInner: React.FC<StreamViewProps> = ({
133
80
  : streamStatus === 'error' ? '\u2717 error'
134
81
  : 'idle';
135
82
 
83
+ // Build single text block (1 yoga node instead of 90+)
84
+ const lines = visibleEvents
85
+ .map(e => eventToLine(e))
86
+ .filter(Boolean) as string[];
87
+
88
+ const content = lines.length > 0
89
+ ? lines.join('\n')
90
+ : (waveId ? `Streaming... waiting for ${roleLabel !== 'All' ? roleLabel + ' ' : ''}events` : 'No active stream. Dispatch a wave to start.');
91
+
136
92
  return (
137
- <Box flexDirection="column" paddingX={1} flexGrow={1}>
138
- <Box justifyContent="space-between">
139
- <Text bold color="cyan">
140
- Stream ({roleLabel})
141
- </Text>
93
+ <Box flexDirection="column" paddingX={1}>
94
+ <Text bold color="cyan">
95
+ Stream ({roleLabel}){' '}
142
96
  <Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>
143
97
  {statusLabel} {turnCount > 0 ? `turn ${turnCount}` : ''}
144
98
  </Text>
145
- </Box>
146
-
147
- {visibleEvents.length === 0 && (
148
- <Box marginTop={1}>
149
- <Text color="gray" dimColor>
150
- {waveId
151
- ? `Streaming... waiting for ${roleLabel !== 'All' ? roleLabel + ' ' : ''}events`
152
- : 'No active stream. Dispatch a wave to start.'}
153
- </Text>
154
- </Box>
155
- )}
156
-
157
- {visibleEvents.map((event, i) => {
158
- const rendered = renderEvent(event);
159
- if (!rendered) return null;
160
- const roleColor = getRoleColor(event.roleId, allRoleIds);
161
- return (
162
- <Box key={`${event.seq}-${i}`}>
163
- <Text color="gray" dimColor>{formatTime(event.ts)} </Text>
164
- <Text color={roleColor} bold>{event.roleId.padEnd(12)}</Text>
165
- <Text color={rendered.contentColor} wrap="truncate">{rendered.content}</Text>
166
- </Box>
167
- );
168
- })}
99
+ </Text>
100
+ <Text color="white" wrap="truncate">{content}</Text>
169
101
  </Box>
170
102
  );
171
103
  };