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.
- package/package.json +1 -1
- package/src/api/src/create-server.ts +2 -0
- package/src/api/src/services/execution-manager.ts +2 -1
- package/src/api/src/services/supervisor-heartbeat.ts +4 -1
- package/src/tui/app.tsx +4 -2
- package/src/tui/components/OrgTree.tsx +15 -82
- package/src/tui/components/PanelMode.tsx +210 -473
- package/src/tui/components/StreamView.tsx +45 -113
- package/src/web/dist/assets/index-C6r_vHBI.js +138 -0
- package/src/web/dist/assets/{index-uwS0YSTU.js → index-Czp8wshq.js} +1 -1
- package/src/web/dist/assets/index-DVKWFwwK.css +1 -0
- package/src/web/dist/assets/{preview-app-CAohaHWp.js → preview-app-CMGFfqT-.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-A3-TBmWZ.js +0 -138
- package/src/web/dist/assets/index-D1RTvnx7.css +0 -1
|
@@ -1,30 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PanelMode — Wave-scoped team view
|
|
2
|
+
* PanelMode — Wave-scoped team view (text-based render)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
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
|
|
49
|
+
return s;
|
|
73
50
|
}
|
|
74
51
|
|
|
75
|
-
function
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
103
|
-
mdFileCache = { root: companyRoot, files: results };
|
|
104
|
-
return results;
|
|
64
|
+
return result;
|
|
105
65
|
}
|
|
106
66
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
155
|
-
process.stdout.on('resize',
|
|
156
|
-
return () => { process.stdout.off('resize',
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
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)
|
|
119
|
+
if (idx > 0) setRightTab(tabs[idx - 1]);
|
|
240
120
|
return;
|
|
241
121
|
}
|
|
242
|
-
if (input === 'l' ||
|
|
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)
|
|
125
|
+
if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
|
|
246
126
|
return;
|
|
247
127
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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 (
|
|
263
|
-
if (rightTab === 'docs') {
|
|
264
|
-
|
|
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' &&
|
|
297
|
-
const editor = process.env.EDITOR || 'vim';
|
|
140
|
+
if (rightTab === 'docs' && selectedDocPath) {
|
|
298
141
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
311
|
-
if (
|
|
312
|
-
|
|
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
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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 {
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
<
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
<Text color="gray" dimColor>
|
|
559
|
-
|
|
560
|
-
|
|
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
|
};
|