tycono 0.3.14-beta.2 → 0.3.14-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,54 @@ 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
- }
117
- }
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) ?? '').replace(/\n/g, ' ').trim(); return x ? `${t} ${r} ${x.slice(0, 120)}` : null; }
74
+ case 'thinking': { const x = ((ev.data.text as string) ?? '').replace(/\n/g, ' ').trim(); return x ? `${t} ${r} \uD83D\uDCAD ${x.slice(0, 80)}` : null; }
75
+ 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}`; }
76
+ case 'tool:result': return `${t} ${r} \u2190 ${(ev.data.name as string) ?? ''} done`;
77
+ case 'msg:start': return `${t} ${r} \u25B6 Started`;
78
+ case 'msg:done': { const turns = ev.data.turns as number | undefined; return `${t} ${r} \u2713 Done${turns ? ` (${turns} turns)` : ''}`; }
79
+ case 'msg:error': return `${t} ${r} \u2717 ${((ev.data.error ?? ev.data.message) as string ?? '').slice(0, 60)}`;
80
+ case 'dispatch:start': return `${t} ${r} \u21D2 dispatch ${ev.data.targetRole as string ?? ''}`;
81
+ case 'msg:awaiting_input': return `${t} ${r} ? awaiting input`;
82
+ default: return null;
118
83
  }
119
- return Array.from(files);
120
84
  }
121
85
 
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
- }
86
+ /* ─── Component ─── */
141
87
 
142
88
  const PanelModeInner: React.FC<PanelModeProps> = ({
143
89
  tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
@@ -146,420 +92,211 @@ const PanelModeInner: React.FC<PanelModeProps> = ({
146
92
  }) => {
147
93
  const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
148
94
  const [rightTab, setRightTab] = useState<RightTab>('stream');
149
- const [docsFilter, setDocsFilter] = useState<DocsFilter>('all');
150
95
  const [docsIndex, setDocsIndex] = useState(0);
151
- const [docsScroll, setDocsScroll] = useState(0);
152
96
 
153
97
  useEffect(() => {
154
- const onResize = () => setTermHeight(process.stdout.rows || 30);
155
- process.stdout.on('resize', onResize);
156
- return () => { process.stdout.off('resize', onResize); };
98
+ const fn = () => setTermHeight(process.stdout.rows || 30);
99
+ process.stdout.on('resize', fn);
100
+ return () => { process.stdout.off('resize', fn); };
157
101
  }, []);
158
102
 
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]);
103
+ const statuses = useMemo(() => getWaveScopedStatuses(allSessions, focusedWaveId), [allSessions, focusedWaveId]);
104
+ const scopedTree = useMemo(() => {
105
+ const scope = (n: OrgNode): OrgNode => ({ ...n, status: statuses[n.role.id] ?? 'idle', children: n.children.map(scope) });
106
+ return tree.map(scope);
107
+ }, [tree, statuses]);
184
108
 
185
- // Build docs list from filesystem scan + wave files
186
- const docsList = useMemo(() => {
187
- if (rightTab !== 'docs') return [];
188
-
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]);
109
+ const focusedWave = waves.find(w => w.waveId === focusedWaveId);
110
+ const focusedWaveIndex = focusedWaveId ? waves.findIndex(w => w.waveId === focusedWaveId) + 1 : 0;
111
+ const waveSessionCount = focusedWaveId ? allSessions.filter(s => s.waveId === focusedWaveId).length : 0;
231
112
 
113
+ // Key handling
232
114
  useInput((input, key) => {
233
115
  if (key.escape) { onEscape(); return; }
234
-
235
- // h/l: switch right panel tab
236
- if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
116
+ if (input === 'h' || key.leftArrow) {
237
117
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
238
118
  const idx = tabs.indexOf(rightTab);
239
- if (idx > 0) { setRightTab(tabs[idx - 1]); setDocsScroll(0); }
119
+ if (idx > 0) setRightTab(tabs[idx - 1]);
240
120
  return;
241
121
  }
242
- if (input === 'l' || (key.rightArrow && rightTab !== 'info')) {
122
+ if (input === 'l' || key.rightArrow) {
243
123
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
244
124
  const idx = tabs.indexOf(rightTab);
245
- if (idx < tabs.length - 1) { setRightTab(tabs[idx + 1]); setDocsScroll(0); }
125
+ if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
246
126
  return;
247
127
  }
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
- }
128
+ // j/k context-dependent
129
+ if (input === 'k' || key.upArrow) {
130
+ if (rightTab === 'docs') { setDocsIndex(i => Math.max(0, i - 1)); }
131
+ else { onMove('up'); }
260
132
  return;
261
133
  }
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
- }
134
+ if (input === 'j' || key.downArrow) {
135
+ if (rightTab === 'docs') { setDocsIndex(i => i + 1); } // capped later by docsList length
136
+ else { onMove('down'); }
272
137
  return;
273
138
  }
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
139
  if (key.return) {
296
- if (rightTab === 'docs' && selectedDoc) {
297
- const editor = process.env.EDITOR || 'vim';
140
+ if (rightTab === 'docs' && selectedDocPath) {
298
141
  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') {
142
+ const editor = process.env.EDITOR || 'vim';
143
+ execSync(`${editor} "${selectedDocPath}"`, { stdio: 'inherit' });
144
+ } catch { /* ignore */ }
145
+ } else {
305
146
  onSelect();
306
- return;
307
147
  }
148
+ return;
308
149
  }
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
- }
150
+ // Wave switch 1-9
151
+ const num = parseInt(input, 10);
152
+ if (num >= 1 && num <= 9 && num <= waves.length) {
153
+ onFocusWave(waves[num - 1].waveId);
316
154
  }
317
155
  });
318
156
 
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;
157
+ const leftWidth = 28;
158
+ const termCols = process.stdout.columns || 120;
159
+ const rightWidth = termCols - leftWidth - 3;
160
+ const headerLines = 2;
161
+ const footerLines = 3;
162
+ const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
163
+
164
+ // === Build left column: OrgTree ===
165
+ const ceoIcon = statuses['ceo'] === 'working' ? '\u25CF' : statuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
166
+ const isCeoSelected = flatRoles[selectedRoleIndex] === 'ceo';
167
+ const treeEntries = flattenTree(scopedTree);
168
+
169
+ const leftLines: Array<{ text: string; selected: boolean; working: boolean }> = [
170
+ { text: `${ceoIcon} CEO`, selected: isCeoSelected, working: statuses['ceo'] === 'working' },
171
+ ...treeEntries.map(e => ({
172
+ text: e.line,
173
+ selected: e.roleId === flatRoles[selectedRoleIndex],
174
+ working: e.status === 'working',
175
+ })),
176
+ ];
177
+
178
+ // === Build right column: Stream/Info/Docs ===
179
+ const rightContentLines: string[] = [];
180
+ let selectedDocPath: string | null = null;
181
+ if (rightTab === 'stream') {
182
+ const maxEv = Math.max(5, contentHeight - 2);
183
+ const filtered = selectedRoleId ? events.filter(e => e.roleId === selectedRoleId) : events;
184
+ const visible = filtered.slice(-maxEv);
185
+ for (const ev of visible) {
186
+ const line = eventLine(ev);
187
+ if (line) rightContentLines.push(line.slice(0, rightWidth));
188
+ }
189
+ if (rightContentLines.length === 0) {
190
+ if (selectedRoleId && events.length > 0) {
191
+ rightContentLines.push(`No events for ${selectedRoleId} (${events.length} total)`);
192
+ rightContentLines.push('Press Enter to show all roles');
193
+ } else {
194
+ rightContentLines.push(waveId ? `Waiting for events... (${events.length} in buffer)` : 'No active stream. Type a directive to start.');
195
+ }
196
+ }
197
+ } else if (rightTab === 'info') {
198
+ rightContentLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
199
+ rightContentLines.push(`Directive: ${focusedWave?.directive?.slice(0, rightWidth - 12) || '(idle)'}`);
200
+ rightContentLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
201
+ rightContentLines.push(`Stream: ${streamStatus}`);
202
+ } else if (rightTab === 'docs') {
203
+ // Docs: scan .md files with j/k scroll + Enter to open
344
204
  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;
205
+ const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude', '.obsidian']);
206
+ const mdFiles: string[] = [];
207
+ const mdPaths: string[] = []; // full paths for vim
208
+ const walk = (dir: string, depth: number) => {
209
+ if (depth > 3 || mdFiles.length > 200) return;
210
+ try {
211
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
212
+ if (skip.has(e.name)) continue;
213
+ const full = path.join(dir, e.name);
214
+ if (e.isDirectory()) walk(full, depth + 1);
215
+ else if (e.name.endsWith('.md')) {
216
+ mdFiles.push(full.replace(companyRoot + '/', ''));
217
+ mdPaths.push(full);
218
+ }
219
+ }
220
+ } catch {}
221
+ };
222
+ walk(companyRoot, 0);
223
+ mdFiles.sort();
224
+ mdPaths.sort();
225
+ // Cap docsIndex
226
+ const cappedIdx = Math.min(docsIndex, mdFiles.length - 1);
227
+ if (cappedIdx !== docsIndex) setDocsIndex(Math.max(0, cappedIdx));
228
+ selectedDocPath = mdPaths[cappedIdx] ?? null;
229
+
230
+ const maxVisible = Math.max(5, termHeight - 12);
231
+ const scrollStart = Math.max(0, Math.min(cappedIdx - 3, mdFiles.length - maxVisible));
232
+ rightContentLines.push(`${mdFiles.length} documents [j/k] browse [Enter] ${process.env.EDITOR || 'vim'}`);
233
+ for (let i = scrollStart; i < Math.min(scrollStart + maxVisible, mdFiles.length); i++) {
234
+ const selected = i === cappedIdx;
235
+ const prefix = selected ? '\u25B6 ' : ' ';
236
+ rightContentLines.push(`${prefix}${mdFiles[i].slice(0, rightWidth - 4)}`);
349
237
  }
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" overflow="hidden">
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
- )}
238
+ } catch {
239
+ rightContentLines.push('Cannot scan documents');
240
+ }
241
+ }
455
242
 
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>
243
+ // === Merge left + right, cap to terminal height ===
244
+ const maxRows = contentHeight;
245
+
246
+ const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
247
+ for (let i = 0; i < maxRows; i++) {
248
+ const ll = leftLines[i];
249
+ rows.push({
250
+ left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
251
+ right: rightContentLines[i] ?? '',
252
+ leftSelected: ll?.selected ?? false,
253
+ leftWorking: ll?.working ?? false,
254
+ });
255
+ }
474
256
 
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
- )}
257
+ // Tab bar
258
+ const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
259
+ t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
260
+ ).join(' ');
507
261
 
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
- )}
262
+ // Wave tabs
263
+ const waveTabs = waves.length > 1
264
+ ? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
265
+ : '';
517
266
 
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>
267
+ const sep = '\u2500'.repeat(Math.min(termCols, 160));
268
+ const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
529
269
 
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>
270
+ return (
271
+ <Box flexDirection="column">
272
+ {/* Header */}
273
+ <Text>
274
+ <Text color="green" bold>{'W' + focusedWaveIndex}</Text>
275
+ <Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
276
+ <Text color="gray">{' \u2502 '}</Text>
277
+ <Text color="cyan" bold>{tabBar}</Text>
278
+ <Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
279
+ <Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
280
+ </Text>
281
+ <Text color="gray">{sep}</Text>
282
+
283
+ {/* Content rows: left (OrgTree) │ right (Stream/Info) */}
284
+ {rows.map((row, i) => (
285
+ <Text key={i}>
286
+ <Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
287
+ <Text color="gray">{' \u2502 '}</Text>
288
+ <Text color="white">{row.right}</Text>
289
+ </Text>
290
+ ))}
552
291
 
553
292
  {/* 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>
293
+ <Text color="gray">{sep}</Text>
294
+ <Text>
295
+ {waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
296
+ <Text color="gray" dimColor>{rightTab === 'docs' ? '[h/l] tab [j/k] browse [Enter] open ' : '[h/l] tab [j/k] role [Enter] filter '}</Text>
297
+ {waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
298
+ <Text color="gray" dimColor>{'[Esc] back'}</Text>
299
+ </Text>
563
300
  </Box>
564
301
  );
565
302
  };