tycono 0.3.14-beta.9 → 0.3.15

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,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,296 @@ 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);
103
+ const [docsFilter, setDocsFilter] = useState<'all' | 'wave' | 'kb' | 'projects'>('all');
104
+ const [docsPreview, setDocsPreview] = useState(false); // true = file preview mode
152
105
 
153
106
  useEffect(() => {
154
- const onResize = () => setTermHeight(process.stdout.rows || 30);
155
- process.stdout.on('resize', onResize);
156
- return () => { process.stdout.off('resize', onResize); };
107
+ const fn = () => setTermHeight(process.stdout.rows || 30);
108
+ process.stdout.on('resize', fn);
109
+ return () => { process.stdout.off('resize', fn); };
157
110
  }, []);
158
111
 
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]);
112
+ const statuses = useMemo(() => getWaveScopedStatuses(allSessions, focusedWaveId), [allSessions, focusedWaveId]);
113
+ const scopedTree = useMemo(() => {
114
+ const scope = (n: OrgNode): OrgNode => ({ ...n, status: statuses[n.role.id] ?? 'idle', children: n.children.map(scope) });
115
+ return tree.map(scope);
116
+ }, [tree, statuses]);
184
117
 
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]);
118
+ const focusedWave = waves.find(w => w.waveId === focusedWaveId);
119
+ const focusedWaveIndex = focusedWaveId ? waves.findIndex(w => w.waveId === focusedWaveId) + 1 : 0;
120
+ const waveSessionCount = focusedWaveId ? allSessions.filter(s => s.waveId === focusedWaveId).length : 0;
231
121
 
122
+ // Key handling
232
123
  useInput((input, key) => {
233
- if (key.escape) { onEscape(); return; }
234
-
235
- // h/l: switch right panel tab
236
- if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
124
+ if (key.escape) {
125
+ if (docsPreview) { setDocsPreview(false); return; }
126
+ onEscape(); return;
127
+ }
128
+ if (input === 'h' || key.leftArrow) {
237
129
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
238
130
  const idx = tabs.indexOf(rightTab);
239
- if (idx > 0) { setRightTab(tabs[idx - 1]); setDocsScroll(0); }
131
+ if (idx > 0) setRightTab(tabs[idx - 1]);
240
132
  return;
241
133
  }
242
- if (input === 'l' || (key.rightArrow && rightTab !== 'info')) {
134
+ if (input === 'l' || key.rightArrow) {
243
135
  const tabs: RightTab[] = ['stream', 'docs', 'info'];
244
136
  const idx = tabs.indexOf(rightTab);
245
- if (idx < tabs.length - 1) { setRightTab(tabs[idx + 1]); setDocsScroll(0); }
137
+ if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
246
138
  return;
247
139
  }
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
- }
140
+ // j/k context-dependent
141
+ if (input === 'k' || key.upArrow) {
142
+ if (rightTab === 'docs') { setDocsIndex(i => Math.max(0, i - 1)); }
143
+ else { onMove('up'); }
144
+ return;
145
+ }
146
+ if (input === 'j' || key.downArrow) {
147
+ if (rightTab === 'docs') { setDocsIndex(i => i + 1); } // capped later by docsList length
148
+ else { onMove('down'); }
260
149
  return;
261
150
  }
262
- if (key.downArrow || input === 'j') {
263
- if (rightTab === 'docs') {
264
- if (docsScroll > 0) {
265
- setDocsScroll(s => s + 3);
151
+ if (key.return) {
152
+ if (rightTab === 'docs' && selectedDocPath) {
153
+ if (docsPreview) {
154
+ // In preview open in vim
155
+ try {
156
+ const editor = process.env.EDITOR || 'vim';
157
+ execSync(`${editor} "${selectedDocPath}"`, { stdio: 'inherit' });
158
+ } catch { /* ignore */ }
159
+ setDocsPreview(false);
266
160
  } else {
267
- setDocsIndex(i => Math.min(docsList.length - 1, i + 1));
161
+ // In list toggle preview
162
+ setDocsPreview(true);
268
163
  }
269
- } else if (rightTab === 'stream') {
270
- onMove('down');
164
+ return;
165
+ } else {
166
+ onSelect();
271
167
  }
272
168
  return;
273
169
  }
274
-
275
- // Docs filter: 1-4
170
+ // Docs filter 1-4
276
171
  if (rightTab === 'docs') {
277
- const filters: DocsFilter[] = ['all', 'wave', 'kb', 'projects'];
172
+ const filters = ['all', 'wave', 'kb', 'projects'] as const;
278
173
  const fi = parseInt(input, 10);
279
174
  if (fi >= 1 && fi <= 4) {
280
175
  setDocsFilter(filters[fi - 1]);
281
176
  setDocsIndex(0);
282
- setDocsScroll(0);
177
+ setDocsPreview(false);
283
178
  return;
284
179
  }
285
180
  }
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;
181
+ // Wave switch 1-9 (not in docs filter mode)
182
+ const num = parseInt(input, 10);
183
+ if (rightTab !== 'docs' && num >= 1 && num <= 9 && num <= waves.length) {
184
+ onFocusWave(waves[num - 1].waveId);
292
185
  }
186
+ });
293
187
 
294
- // Enter
295
- if (key.return) {
296
- if (rightTab === 'docs' && selectedDoc) {
297
- const editor = process.env.EDITOR || 'vim';
298
- try {
299
- execSync(`${editor} "${selectedDoc.path}"`, { stdio: 'inherit' });
300
- } catch { /* user quit editor */ }
301
- fileCache.delete(selectedDoc.path); // Invalidate cache after edit
302
- return;
188
+ const leftWidth = 28;
189
+ const termCols = process.stdout.columns || 120;
190
+ const rightWidth = termCols - leftWidth - 3;
191
+ const headerLines = 2;
192
+ const footerLines = 3;
193
+ const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
194
+
195
+ // === Build left column: OrgTree ===
196
+ const ceoIcon = statuses['ceo'] === 'working' ? '\u25CF' : statuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
197
+ const isCeoSelected = flatRoles[selectedRoleIndex] === 'ceo';
198
+ const treeEntries = flattenTree(scopedTree);
199
+
200
+ const leftLines: Array<{ text: string; selected: boolean; working: boolean }> = [
201
+ { text: `${ceoIcon} CEO`, selected: isCeoSelected, working: statuses['ceo'] === 'working' },
202
+ ...treeEntries.map(e => ({
203
+ text: e.line,
204
+ selected: e.roleId === flatRoles[selectedRoleIndex],
205
+ working: e.status === 'working',
206
+ })),
207
+ ];
208
+
209
+ // Derive selectedRoleId from index (more reliable than prop — avoids sync issues)
210
+ const activeRoleId = flatRoles[selectedRoleIndex] ?? null;
211
+
212
+ // === Build right column: Stream/Info/Docs ===
213
+ const rightContentLines: string[] = [];
214
+ let selectedDocPath: string | null = null;
215
+ if (rightTab === 'stream') {
216
+ if (activeRoleId) rightContentLines.push(`\u25B8 ${activeRoleId}`);
217
+ const maxEv = Math.max(5, contentHeight - 3);
218
+ const filtered = activeRoleId ? events.filter(e => e.roleId === activeRoleId) : events;
219
+ const visible = filtered.slice(-maxEv);
220
+ for (const ev of visible) {
221
+ const line = eventLine(ev);
222
+ if (!line) continue;
223
+ // Split multi-line text events into separate lines (preserves markdown)
224
+ const sublines = line.split('\n');
225
+ for (const sl of sublines) {
226
+ if (rightContentLines.length >= maxEv) break;
227
+ rightContentLines.push(sl.slice(0, rightWidth));
303
228
  }
304
- if (rightTab === 'stream') {
305
- onSelect();
306
- return;
229
+ }
230
+ if (rightContentLines.length === 0) {
231
+ if (activeRoleId && events.length > 0) {
232
+ rightContentLines.push(`No events for ${activeRoleId} (${events.length} total)`);
233
+ rightContentLines.push('Press Enter to show all roles');
234
+ } else {
235
+ rightContentLines.push(waveId ? `Waiting for events... (${events.length} in buffer)` : 'No active stream. Type a directive to start.');
307
236
  }
308
237
  }
238
+ } else if (rightTab === 'info') {
239
+ rightContentLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
240
+ rightContentLines.push(`Directive: ${focusedWave?.directive?.slice(0, rightWidth - 12) || '(idle)'}`);
241
+ rightContentLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
242
+ rightContentLines.push(`Stream: ${streamStatus}`);
243
+ } else if (rightTab === 'docs') {
244
+ // Docs: scan + filter + ★ wave artifacts + preview
245
+ try {
246
+ const skipDirs = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude', '.obsidian']);
247
+ const allMdFiles: Array<{ rel: string; full: string }> = [];
248
+ const walk = (dir: string, depth: number) => {
249
+ if (depth > 3 || allMdFiles.length > 300) return;
250
+ try {
251
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
252
+ if (skipDirs.has(e.name)) continue;
253
+ const full = path.join(dir, e.name);
254
+ if (e.isDirectory()) walk(full, depth + 1);
255
+ else if (e.name.endsWith('.md')) {
256
+ allMdFiles.push({ rel: full.replace(companyRoot + '/', ''), full });
257
+ }
258
+ }
259
+ } catch {}
260
+ };
261
+ walk(companyRoot, 0);
262
+ allMdFiles.sort((a, b) => a.rel.localeCompare(b.rel));
263
+
264
+ // Wave artifact files (from SSE events)
265
+ const waveFiles = new Set<string>();
266
+ for (const ev of events) {
267
+ if (ev.type === 'tool:start') {
268
+ const name = (ev.data.name as string) ?? '';
269
+ const inp = ev.data.input as Record<string, unknown> | undefined;
270
+ if (['Write', 'Edit', 'NotebookEdit'].includes(name) && inp?.file_path) {
271
+ waveFiles.add(String(inp.file_path));
272
+ }
273
+ }
274
+ }
309
275
 
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);
276
+ // Apply filter
277
+ const filtered = allMdFiles.filter(f => {
278
+ if (docsFilter === 'wave') return waveFiles.has(f.full);
279
+ if (docsFilter === 'kb') return f.rel.startsWith('knowledge/');
280
+ if (docsFilter === 'projects') return f.rel.startsWith('projects/');
281
+ return true; // 'all'
282
+ });
283
+
284
+ // Sort: wave files first
285
+ filtered.sort((a, b) => {
286
+ const aw = waveFiles.has(a.full) ? 0 : 1;
287
+ const bw = waveFiles.has(b.full) ? 0 : 1;
288
+ if (aw !== bw) return aw - bw;
289
+ return a.rel.localeCompare(b.rel);
290
+ });
291
+
292
+ // Cap docsIndex
293
+ const cappedIdx = Math.min(docsIndex, Math.max(0, filtered.length - 1));
294
+ if (cappedIdx !== docsIndex) setDocsIndex(cappedIdx);
295
+ selectedDocPath = filtered[cappedIdx]?.full ?? null;
296
+
297
+ if (docsPreview && selectedDocPath) {
298
+ // === Preview mode ===
299
+ const previewLines: string[] = [];
300
+ try {
301
+ const content = fs.readFileSync(selectedDocPath, 'utf-8');
302
+ previewLines.push(...content.split('\n').slice(0, contentHeight - 3));
303
+ } catch { previewLines.push('(cannot read)'); }
304
+ const shortName = selectedDocPath.split('/').slice(-2).join('/');
305
+ rightContentLines.push(`${waveFiles.has(selectedDocPath) ? '\u2605 ' : ''}${shortName} [Esc] back [Enter] ${process.env.EDITOR || 'vim'}`);
306
+ rightContentLines.push('\u2500'.repeat(Math.min(50, rightWidth)));
307
+ for (const pl of previewLines) {
308
+ if (rightContentLines.length >= contentHeight) break;
309
+ rightContentLines.push(pl.slice(0, rightWidth));
310
+ }
311
+ } else {
312
+ // === List mode ===
313
+ const filterLabels = ['1:All', '2:\u2605Wave', '3:KB', '4:Projects'];
314
+ const filterBar = filterLabels.map((f, i) => {
315
+ const key = ['all', 'wave', 'kb', 'projects'][i];
316
+ return key === docsFilter ? `[${f}]` : ` ${f} `;
317
+ }).join(' ');
318
+ rightContentLines.push(`${filterBar} ${filtered.length} docs [j/k] browse [Enter] preview`);
319
+
320
+ const maxVisible = Math.max(5, contentHeight - 3);
321
+ const scrollStart = Math.max(0, Math.min(cappedIdx - 3, filtered.length - maxVisible));
322
+ for (let i = scrollStart; i < Math.min(scrollStart + maxVisible, filtered.length); i++) {
323
+ const selected = i === cappedIdx;
324
+ const isWave = waveFiles.has(filtered[i].full);
325
+ const prefix = selected ? '\u25B6 ' : ' ';
326
+ const star = isWave ? '\u2605' : ' ';
327
+ rightContentLines.push(`${prefix}${star} ${filtered[i].rel.slice(0, rightWidth - 6)}`);
328
+ }
315
329
  }
330
+ } catch {
331
+ rightContentLines.push('Cannot scan documents');
316
332
  }
317
- });
318
-
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;
333
+ }
331
334
 
332
- const focusedWave = waves.find(w => w.waveId === focusedWaveId);
333
- const focusedWaveIndex = focusedWaveId
334
- ? waves.findIndex(w => w.waveId === focusedWaveId) + 1
335
- : 0;
335
+ // === Merge left + right, cap to terminal height ===
336
+ const maxRows = contentHeight;
337
+
338
+ const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
339
+ for (let i = 0; i < maxRows; i++) {
340
+ const ll = leftLines[i];
341
+ rows.push({
342
+ left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
343
+ right: rightContentLines[i] ?? '',
344
+ leftSelected: ll?.selected ?? false,
345
+ leftWorking: ll?.working ?? false,
346
+ });
347
+ }
336
348
 
337
- const waveSessionCount = focusedWaveId
338
- ? allSessions.filter(s => s.waveId === focusedWaveId).length
339
- : 0;
349
+ // Tab bar
350
+ const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
351
+ t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
352
+ ).join(' ');
340
353
 
341
- // Read preset from wave file on disk
342
- const wavePreset = useMemo(() => {
343
- if (!focusedWaveId || !companyRoot) return null;
344
- 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;
349
- }
350
- } catch { /* ignore */ }
351
- return null;
352
- }, [focusedWaveId, companyRoot]);
354
+ // Wave tabs
355
+ const waveTabs = waves.length > 1
356
+ ? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
357
+ : '';
353
358
 
354
- const leftWidth = 28;
359
+ const sep = '\u2500'.repeat(Math.min(termCols, 160));
360
+ const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
355
361
 
356
362
  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
- )}
455
-
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>
474
-
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
- )}
507
-
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
- )}
517
-
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>
529
-
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>
363
+ <Box flexDirection="column">
364
+ {/* Header */}
365
+ <Text>
366
+ <Text color="green" bold>{'W' + focusedWaveIndex}</Text>
367
+ <Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
368
+ <Text color="gray">{' \u2502 '}</Text>
369
+ <Text color="cyan" bold>{tabBar}</Text>
370
+ <Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
371
+ <Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
372
+ </Text>
373
+ <Text color="gray">{sep}</Text>
374
+
375
+ {/* Content rows: left (OrgTree) │ right (Stream/Info) */}
376
+ {rows.map((row, i) => (
377
+ <Text key={i}>
378
+ <Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
379
+ <Text color="gray">{' \u2502 '}</Text>
380
+ <Text color="white">{row.right}</Text>
381
+ </Text>
382
+ ))}
552
383
 
553
384
  {/* 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>
385
+ <Text color="gray">{sep}</Text>
386
+ <Text>
387
+ {waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
388
+ <Text color="gray" dimColor>{rightTab === 'docs' ? '[h/l] tab [j/k] browse [Enter] open ' : '[h/l] tab [j/k] role [Enter] filter '}</Text>
389
+ {waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
390
+ <Text color="gray" dimColor>{'[Esc] back'}</Text>
391
+ </Text>
563
392
  </Box>
564
393
  );
565
394
  };