tycono 0.3.14-beta.14 → 0.3.14-beta.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/tui/app.tsx +2 -47
- package/src/tui/components/PanelMode.tsx +124 -357
package/package.json
CHANGED
package/src/tui/app.tsx
CHANGED
|
@@ -14,8 +14,6 @@
|
|
|
14
14
|
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
15
15
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
16
16
|
import { StatusBar } from './components/StatusBar';
|
|
17
|
-
import { OrgTree } from './components/OrgTree';
|
|
18
|
-
import { StreamView } from './components/StreamView';
|
|
19
17
|
import { CommandMode, type StreamLine } from './components/CommandMode';
|
|
20
18
|
import { PanelMode } from './components/PanelMode';
|
|
21
19
|
import { SetupWizard } from './components/SetupWizard';
|
|
@@ -670,52 +668,9 @@ export const App: React.FC = () => {
|
|
|
670
668
|
// Command Mode: scrollable terminal (no fullscreen)
|
|
671
669
|
// Panel Mode: fullscreen (intentional — like vim for inspection)
|
|
672
670
|
if (mode === 'panel') {
|
|
673
|
-
// OOM debug levels: 0=full, 1=minimal, 2=orgTree only, 3=stream only
|
|
674
|
-
const debugLevel = parseInt(process.env.PANEL_MINIMAL || '0', 10);
|
|
675
|
-
if (debugLevel === 1) {
|
|
676
|
-
return (
|
|
677
|
-
<Box flexDirection="column">
|
|
678
|
-
<Text color="cyan">Panel Mode (minimal)</Text>
|
|
679
|
-
<Text color="gray">Events: {sse.events.length} | Press Esc</Text>
|
|
680
|
-
</Box>
|
|
681
|
-
);
|
|
682
|
-
}
|
|
683
|
-
if (debugLevel === 2) {
|
|
684
|
-
return (
|
|
685
|
-
<Box flexDirection="column">
|
|
686
|
-
<OrgTree tree={orgTree} focused={true} selectedIndex={0} flatRoles={flatRoleIds} ceoStatus="idle" />
|
|
687
|
-
<Text color="gray">OrgTree only | Press Esc</Text>
|
|
688
|
-
</Box>
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
if (debugLevel === 3) {
|
|
692
|
-
return (
|
|
693
|
-
<Box flexDirection="column">
|
|
694
|
-
<StreamView events={sse.events} allRoleIds={flatRoleIds} streamStatus={sse.streamStatus} waveId={focusedWaveId} roleLabel="All" />
|
|
695
|
-
<Text color="gray">StreamView only | Press Esc</Text>
|
|
696
|
-
</Box>
|
|
697
|
-
);
|
|
698
|
-
}
|
|
699
|
-
if (debugLevel === 4) {
|
|
700
|
-
// Full layout structure but empty content
|
|
701
|
-
return (
|
|
702
|
-
<Box flexDirection="column" height={termHeight}>
|
|
703
|
-
<Box flexGrow={1}>
|
|
704
|
-
<Box flexDirection="column" width={28}>
|
|
705
|
-
<Text color="green">Left Panel</Text>
|
|
706
|
-
</Box>
|
|
707
|
-
<Text color="gray">{'\u2502'}</Text>
|
|
708
|
-
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
|
709
|
-
<Text color="cyan">Right Panel</Text>
|
|
710
|
-
</Box>
|
|
711
|
-
</Box>
|
|
712
|
-
<StatusBar companyName="test" waveIndex={1} waveCount={1} waveStatus="idle" activeCount={0} portCount={0} totalCost={0} />
|
|
713
|
-
</Box>
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
671
|
return (
|
|
717
|
-
<Box flexDirection="column"
|
|
718
|
-
<Box
|
|
672
|
+
<Box flexDirection="column">
|
|
673
|
+
<Box flexDirection="column">
|
|
719
674
|
<PanelMode
|
|
720
675
|
tree={orgTree}
|
|
721
676
|
flatRoles={flatRoleIds}
|
|
@@ -1,30 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PanelMode — Wave-scoped team view
|
|
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';
|
|
18
|
-
import { execSync } from 'node:child_process';
|
|
19
|
-
import { OrgTree } from './OrgTree';
|
|
20
|
-
import { StreamView } from './StreamView';
|
|
21
|
-
import type { OrgNode } from '../store';
|
|
22
12
|
import path from 'node:path';
|
|
13
|
+
import type { OrgNode } from '../store';
|
|
23
14
|
import type { SSEEvent, ActiveSessionInfo, SessionInfo } from '../api';
|
|
24
15
|
import type { WaveInfo } from '../hooks/useCommand';
|
|
25
16
|
|
|
26
17
|
type RightTab = 'stream' | 'docs' | 'info';
|
|
27
|
-
type DocsFilter = 'all' | 'wave' | 'kb' | 'projects';
|
|
28
18
|
|
|
29
19
|
interface PanelModeProps {
|
|
30
20
|
tree: OrgNode[];
|
|
@@ -45,8 +35,21 @@ interface PanelModeProps {
|
|
|
45
35
|
onFocusWave: (waveId: string) => void;
|
|
46
36
|
}
|
|
47
37
|
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
/* ─── Helpers ─── */
|
|
39
|
+
|
|
40
|
+
function getWaveScopedStatuses(sessions: SessionInfo[], waveId: string | null): Record<string, string> {
|
|
41
|
+
if (!waveId) return {};
|
|
42
|
+
const s: Record<string, string> = {};
|
|
43
|
+
for (const ses of sessions) {
|
|
44
|
+
if (ses.waveId !== waveId) continue;
|
|
45
|
+
if (ses.status === 'active') s[ses.roleId] = 'working';
|
|
46
|
+
else if (!s[ses.roleId]) s[ses.roleId] = 'done';
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function flattenTree(nodes: OrgNode[], isLast: boolean[] = []): Array<{ roleId: string; status: string; line: string }> {
|
|
52
|
+
const result: Array<{ roleId: string; status: string; line: string }> = [];
|
|
50
53
|
for (let i = 0; i < nodes.length; i++) {
|
|
51
54
|
const node = nodes[i];
|
|
52
55
|
const last = i === nodes.length - 1;
|
|
@@ -54,120 +57,32 @@ function flattenTreeForText(nodes: OrgNode[], isLast: boolean[] = []): string[]
|
|
|
54
57
|
for (const l of isLast) prefix += l ? ' ' : '\u2502 ';
|
|
55
58
|
prefix += last ? '\u2514\u2500 ' : '\u251C\u2500 ';
|
|
56
59
|
const icon = node.status === 'working' ? '\u25CF' : node.status === 'done' ? '\u2713' : '\u25CB';
|
|
57
|
-
|
|
58
|
-
if (node.children.length > 0)
|
|
60
|
+
result.push({ roleId: node.role.id, status: node.status, line: `${prefix}${icon} ${node.role.id}` });
|
|
61
|
+
if (node.children.length > 0) result.push(...flattenTree(node.children, [...isLast, last]));
|
|
59
62
|
}
|
|
60
|
-
return
|
|
63
|
+
return result;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
case '
|
|
70
|
-
case '
|
|
71
|
-
case '
|
|
72
|
-
case '
|
|
73
|
-
case '
|
|
66
|
+
function eventLine(ev: SSEEvent): string | null {
|
|
67
|
+
let t: string;
|
|
68
|
+
try { t = new Date(ev.ts).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
|
|
69
|
+
catch { t = '--:--:--'; }
|
|
70
|
+
const r = (ev.roleId ?? '').padEnd(12);
|
|
71
|
+
switch (ev.type) {
|
|
72
|
+
case 'text': { const x = ((ev.data.text as string) ?? '').trim(); return x ? `${t} ${r} ${x.slice(0, 120)}` : null; }
|
|
73
|
+
case 'thinking': { const x = ((ev.data.text as string) ?? '').trim(); return x ? `${t} ${r} \uD83D\uDCAD ${x.slice(0, 80)}` : null; }
|
|
74
|
+
case 'tool:start': { const n = (ev.data.name as string) ?? ''; const d = ev.data.input ? (((ev.data.input as any).file_path || (ev.data.input as any).command || (ev.data.input as any).pattern || '') as string).slice(0, 50) : ''; return `${t} ${r} \u2192 ${n} ${d}`; }
|
|
75
|
+
case 'tool:result': return `${t} ${r} \u2190 ${(ev.data.name as string) ?? ''} done`;
|
|
76
|
+
case 'msg:start': return `${t} ${r} \u25B6 Started`;
|
|
77
|
+
case 'msg:done': { const turns = ev.data.turns as number | undefined; return `${t} ${r} \u2713 Done${turns ? ` (${turns} turns)` : ''}`; }
|
|
78
|
+
case 'msg:error': return `${t} ${r} \u2717 ${((ev.data.error ?? ev.data.message) as string ?? '').slice(0, 60)}`;
|
|
79
|
+
case 'dispatch:start': return `${t} ${r} \u21D2 dispatch ${ev.data.targetRole as string ?? ''}`;
|
|
80
|
+
case 'msg:awaiting_input': return `${t} ${r} ? awaiting input`;
|
|
74
81
|
default: return null;
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
allSessions: SessionInfo[],
|
|
80
|
-
focusedWaveId: string | null,
|
|
81
|
-
): Record<string, string> {
|
|
82
|
-
if (!focusedWaveId) return {};
|
|
83
|
-
const statuses: Record<string, string> = {};
|
|
84
|
-
for (const s of allSessions) {
|
|
85
|
-
if (s.waveId !== focusedWaveId) continue;
|
|
86
|
-
if (s.status === 'active') statuses[s.roleId] = 'working';
|
|
87
|
-
else if (!statuses[s.roleId]) statuses[s.roleId] = 'done';
|
|
88
|
-
}
|
|
89
|
-
return statuses;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function findSessionForRole(
|
|
93
|
-
activeSessions: ActiveSessionInfo[],
|
|
94
|
-
allSessions: SessionInfo[],
|
|
95
|
-
roleId: string,
|
|
96
|
-
focusedWaveId: string | null,
|
|
97
|
-
): ActiveSessionInfo | null {
|
|
98
|
-
if (focusedWaveId) {
|
|
99
|
-
const waveSes = allSessions.find(s => s.waveId === focusedWaveId && s.roleId === roleId && s.status === 'active');
|
|
100
|
-
if (waveSes) return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
|
|
101
|
-
}
|
|
102
|
-
return activeSessions.find(s => s.roleId === roleId && s.status === 'active') ?? null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function elapsed(startedAt: string): string {
|
|
106
|
-
const ms = Date.now() - new Date(startedAt).getTime();
|
|
107
|
-
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
108
|
-
if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
109
|
-
return `${Math.floor(ms / 3600_000)}h`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Scan COMPANY_ROOT for .md files (cached) */
|
|
113
|
-
let mdFileCache: { root: string; files: string[] } | null = null;
|
|
114
|
-
function scanMdFiles(companyRoot: string): string[] {
|
|
115
|
-
if (mdFileCache && mdFileCache.root === companyRoot) return mdFileCache.files;
|
|
116
|
-
const results: string[] = [];
|
|
117
|
-
const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude']);
|
|
118
|
-
function walk(dir: string, depth: number) {
|
|
119
|
-
if (depth > 3) return; // Don't go too deep
|
|
120
|
-
try {
|
|
121
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
122
|
-
if (skip.has(entry.name)) continue;
|
|
123
|
-
const full = path.join(dir, entry.name);
|
|
124
|
-
if (entry.isDirectory()) {
|
|
125
|
-
walk(full, depth + 1);
|
|
126
|
-
} else if (entry.name.endsWith('.md')) {
|
|
127
|
-
results.push(full);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
} catch { /* permission error etc */ }
|
|
131
|
-
}
|
|
132
|
-
walk(companyRoot, 0);
|
|
133
|
-
mdFileCache = { root: companyRoot, files: results };
|
|
134
|
-
return results;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Extract files created/modified in this wave from SSE events */
|
|
138
|
-
function extractWaveFiles(events: SSEEvent[]): string[] {
|
|
139
|
-
const files = new Set<string>();
|
|
140
|
-
for (const e of events) {
|
|
141
|
-
if (e.type === 'tool:start') {
|
|
142
|
-
const name = (e.data.name as string) ?? '';
|
|
143
|
-
const input = e.data.input as Record<string, unknown> | undefined;
|
|
144
|
-
if (['Write', 'Edit', 'NotebookEdit'].includes(name) && input?.file_path) {
|
|
145
|
-
files.add(String(input.file_path));
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return Array.from(files);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Read file preview (first N lines, cached) */
|
|
153
|
-
const fileCache = new Map<string, string[]>();
|
|
154
|
-
function readFilePreview(filePath: string, maxLines: number): string[] {
|
|
155
|
-
const cached = fileCache.get(filePath);
|
|
156
|
-
if (cached) return cached;
|
|
157
|
-
try {
|
|
158
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
159
|
-
const lines = content.split('\n').slice(0, maxLines);
|
|
160
|
-
fileCache.set(filePath, lines);
|
|
161
|
-
// Evict old entries
|
|
162
|
-
if (fileCache.size > 5) {
|
|
163
|
-
const first = fileCache.keys().next().value;
|
|
164
|
-
if (first) fileCache.delete(first);
|
|
165
|
-
}
|
|
166
|
-
return lines;
|
|
167
|
-
} catch {
|
|
168
|
-
return ['(cannot read file)'];
|
|
169
|
-
}
|
|
170
|
-
}
|
|
85
|
+
/* ─── Component ─── */
|
|
171
86
|
|
|
172
87
|
const PanelModeInner: React.FC<PanelModeProps> = ({
|
|
173
88
|
tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
|
|
@@ -176,295 +91,147 @@ const PanelModeInner: React.FC<PanelModeProps> = ({
|
|
|
176
91
|
}) => {
|
|
177
92
|
const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
|
|
178
93
|
const [rightTab, setRightTab] = useState<RightTab>('stream');
|
|
179
|
-
const [docsFilter, setDocsFilter] = useState<DocsFilter>('all');
|
|
180
|
-
const [docsIndex, setDocsIndex] = useState(0);
|
|
181
|
-
const [docsScroll, setDocsScroll] = useState(0);
|
|
182
94
|
|
|
183
95
|
useEffect(() => {
|
|
184
|
-
const
|
|
185
|
-
process.stdout.on('resize',
|
|
186
|
-
return () => { process.stdout.off('resize',
|
|
96
|
+
const fn = () => setTermHeight(process.stdout.rows || 30);
|
|
97
|
+
process.stdout.on('resize', fn);
|
|
98
|
+
return () => { process.stdout.off('resize', fn); };
|
|
187
99
|
}, []);
|
|
188
100
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
() => getWaveScopedStatuses(allSessions, focusedWaveId),
|
|
195
|
-
[allSessions, focusedWaveId],
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
const waveScopedTree = useMemo(() => {
|
|
199
|
-
function scopeNode(node: OrgNode): OrgNode {
|
|
200
|
-
return {
|
|
201
|
-
...node,
|
|
202
|
-
status: waveScopedStatuses[node.role.id] ?? 'idle',
|
|
203
|
-
children: node.children.map(scopeNode),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
return tree.map(scopeNode);
|
|
207
|
-
}, [tree, waveScopedStatuses]);
|
|
208
|
-
|
|
209
|
-
// Wave files (from SSE events) — only compute when needed
|
|
210
|
-
const waveFileSet = useMemo(() => {
|
|
211
|
-
if (rightTab !== 'docs' && rightTab !== 'info') return new Set<string>();
|
|
212
|
-
return new Set(extractWaveFiles(events));
|
|
213
|
-
}, [rightTab === 'docs' || rightTab === 'info' ? events.length : 0, rightTab]);
|
|
214
|
-
|
|
215
|
-
// Build docs list from filesystem scan + wave files
|
|
216
|
-
const docsList = useMemo(() => {
|
|
217
|
-
if (rightTab !== 'docs') return [];
|
|
218
|
-
|
|
219
|
-
interface DocEntry { path: string; title: string; isWave: boolean; }
|
|
220
|
-
const entries: DocEntry[] = [];
|
|
221
|
-
|
|
222
|
-
// Scan all .md files from COMPANY_ROOT
|
|
223
|
-
const allMdFiles = companyRoot ? scanMdFiles(companyRoot) : [];
|
|
224
|
-
|
|
225
|
-
for (const filePath of allMdFiles) {
|
|
226
|
-
const rel = filePath.replace(companyRoot + '/', '');
|
|
227
|
-
const isWave = waveFileSet.has(filePath);
|
|
228
|
-
const isKb = rel.startsWith('knowledge/');
|
|
229
|
-
const isProject = rel.startsWith('projects/');
|
|
230
|
-
|
|
231
|
-
if (docsFilter === 'wave' && !isWave) continue;
|
|
232
|
-
if (docsFilter === 'kb' && !isKb) continue;
|
|
233
|
-
if (docsFilter === 'projects' && !isProject) continue;
|
|
234
|
-
|
|
235
|
-
entries.push({ path: filePath, title: rel, isWave });
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Wave-only files not already in list (e.g. code files written by agents)
|
|
239
|
-
for (const f of waveFileSet) {
|
|
240
|
-
if (!entries.some(e => e.path === f)) {
|
|
241
|
-
if (docsFilter === 'kb' || docsFilter === 'projects') continue;
|
|
242
|
-
entries.push({ path: f, title: f.split('/').pop() || f, isWave: true });
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Sort: wave files first, then alphabetical
|
|
247
|
-
entries.sort((a, b) => {
|
|
248
|
-
if (a.isWave && !b.isWave) return -1;
|
|
249
|
-
if (!a.isWave && b.isWave) return 1;
|
|
250
|
-
return a.title.localeCompare(b.title);
|
|
251
|
-
});
|
|
101
|
+
const statuses = useMemo(() => getWaveScopedStatuses(allSessions, focusedWaveId), [allSessions, focusedWaveId]);
|
|
102
|
+
const scopedTree = useMemo(() => {
|
|
103
|
+
const scope = (n: OrgNode): OrgNode => ({ ...n, status: statuses[n.role.id] ?? 'idle', children: n.children.map(scope) });
|
|
104
|
+
return tree.map(scope);
|
|
105
|
+
}, [tree, statuses]);
|
|
252
106
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const selectedDoc = docsList[docsIndex] ?? null;
|
|
257
|
-
const filePreview = useMemo(() => {
|
|
258
|
-
if (!selectedDoc || rightTab !== 'docs') return [];
|
|
259
|
-
return readFilePreview(selectedDoc.path, 60);
|
|
260
|
-
}, [selectedDoc?.path, rightTab]);
|
|
107
|
+
const focusedWave = waves.find(w => w.waveId === focusedWaveId);
|
|
108
|
+
const focusedWaveIndex = focusedWaveId ? waves.findIndex(w => w.waveId === focusedWaveId) + 1 : 0;
|
|
109
|
+
const waveSessionCount = focusedWaveId ? allSessions.filter(s => s.waveId === focusedWaveId).length : 0;
|
|
261
110
|
|
|
111
|
+
// Key handling
|
|
262
112
|
useInput((input, key) => {
|
|
263
113
|
if (key.escape) { onEscape(); return; }
|
|
264
|
-
|
|
265
|
-
// h/l: switch right panel tab
|
|
266
|
-
if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
|
|
114
|
+
if (input === 'h' || key.leftArrow) {
|
|
267
115
|
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
268
116
|
const idx = tabs.indexOf(rightTab);
|
|
269
|
-
if (idx > 0)
|
|
117
|
+
if (idx > 0) setRightTab(tabs[idx - 1]);
|
|
270
118
|
return;
|
|
271
119
|
}
|
|
272
|
-
if (input === 'l' ||
|
|
120
|
+
if (input === 'l' || key.rightArrow) {
|
|
273
121
|
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
274
122
|
const idx = tabs.indexOf(rightTab);
|
|
275
|
-
if (idx < tabs.length - 1)
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// j/k: context-dependent
|
|
280
|
-
if (key.upArrow || input === 'k') {
|
|
281
|
-
if (rightTab === 'docs') {
|
|
282
|
-
if (docsScroll > 0) {
|
|
283
|
-
setDocsScroll(s => Math.max(0, s - 3));
|
|
284
|
-
} else {
|
|
285
|
-
setDocsIndex(i => Math.max(0, i - 1));
|
|
286
|
-
}
|
|
287
|
-
} else if (rightTab === 'stream') {
|
|
288
|
-
onMove('up');
|
|
289
|
-
}
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
if (key.downArrow || input === 'j') {
|
|
293
|
-
if (rightTab === 'docs') {
|
|
294
|
-
if (docsScroll > 0) {
|
|
295
|
-
setDocsScroll(s => s + 3);
|
|
296
|
-
} else {
|
|
297
|
-
setDocsIndex(i => Math.min(docsList.length - 1, i + 1));
|
|
298
|
-
}
|
|
299
|
-
} else if (rightTab === 'stream') {
|
|
300
|
-
onMove('down');
|
|
301
|
-
}
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Docs filter: 1-4
|
|
306
|
-
if (rightTab === 'docs') {
|
|
307
|
-
const filters: DocsFilter[] = ['all', 'wave', 'kb', 'projects'];
|
|
308
|
-
const fi = parseInt(input, 10);
|
|
309
|
-
if (fi >= 1 && fi <= 4) {
|
|
310
|
-
setDocsFilter(filters[fi - 1]);
|
|
311
|
-
setDocsIndex(0);
|
|
312
|
-
setDocsScroll(0);
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Tab key for cycling docs files
|
|
318
|
-
if (key.tab && rightTab === 'docs') {
|
|
319
|
-
setDocsIndex(i => (i + 1) % Math.max(1, docsList.length));
|
|
320
|
-
setDocsScroll(0);
|
|
123
|
+
if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
|
|
321
124
|
return;
|
|
322
125
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (key.return) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
} catch { /* user quit editor */ }
|
|
331
|
-
fileCache.delete(selectedDoc.path); // Invalidate cache after edit
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
if (rightTab === 'stream') {
|
|
335
|
-
onSelect();
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// 1-9: wave switch (only in stream/info tabs — docs uses 1-4 for filters)
|
|
341
|
-
if (rightTab !== 'docs') {
|
|
342
|
-
const num = parseInt(input, 10);
|
|
343
|
-
if (num >= 1 && num <= 9 && num <= waves.length) {
|
|
344
|
-
onFocusWave(waves[num - 1].waveId);
|
|
345
|
-
}
|
|
126
|
+
if (input === 'k' || key.upArrow) { onMove('up'); return; }
|
|
127
|
+
if (input === 'j' || key.downArrow) { onMove('down'); return; }
|
|
128
|
+
if (key.return) { onSelect(); return; }
|
|
129
|
+
// Wave switch 1-9
|
|
130
|
+
const num = parseInt(input, 10);
|
|
131
|
+
if (num >= 1 && num <= 9 && num <= waves.length) {
|
|
132
|
+
onFocusWave(waves[num - 1].waveId);
|
|
346
133
|
}
|
|
347
134
|
});
|
|
348
135
|
|
|
349
|
-
// Filter events for selected role
|
|
350
|
-
const roleEvents = selectedRoleId
|
|
351
|
-
? events.filter((e) => e.roleId === selectedRoleId)
|
|
352
|
-
: events;
|
|
353
|
-
|
|
354
|
-
const roleLabel = selectedRoleId
|
|
355
|
-
? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
|
|
356
|
-
: 'All';
|
|
357
|
-
|
|
358
|
-
const selectedSession = selectedRoleId
|
|
359
|
-
? findSessionForRole(activeSessions, allSessions, selectedRoleId, focusedWaveId)
|
|
360
|
-
: null;
|
|
361
|
-
|
|
362
|
-
const focusedWave = waves.find(w => w.waveId === focusedWaveId);
|
|
363
|
-
const focusedWaveIndex = focusedWaveId
|
|
364
|
-
? waves.findIndex(w => w.waveId === focusedWaveId) + 1
|
|
365
|
-
: 0;
|
|
366
|
-
|
|
367
|
-
const waveSessionCount = focusedWaveId
|
|
368
|
-
? allSessions.filter(s => s.waveId === focusedWaveId).length
|
|
369
|
-
: 0;
|
|
370
|
-
|
|
371
|
-
// Read preset from wave file on disk
|
|
372
|
-
const wavePreset = useMemo(() => {
|
|
373
|
-
if (!focusedWaveId || !companyRoot) return null;
|
|
374
|
-
try {
|
|
375
|
-
const wavePath = path.join(companyRoot, 'operations', 'waves', `${focusedWaveId}.json`);
|
|
376
|
-
if (fs.existsSync(wavePath)) {
|
|
377
|
-
const data = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
|
|
378
|
-
return data.preset as string | undefined;
|
|
379
|
-
}
|
|
380
|
-
} catch { /* ignore */ }
|
|
381
|
-
return null;
|
|
382
|
-
}, [focusedWaveId, companyRoot]);
|
|
383
|
-
|
|
384
136
|
const leftWidth = 28;
|
|
385
137
|
const termCols = process.stdout.columns || 120;
|
|
386
|
-
const rightWidth =
|
|
387
|
-
|
|
388
|
-
// === Build
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
138
|
+
const rightWidth = termCols - leftWidth - 3;
|
|
139
|
+
|
|
140
|
+
// === Build left column: OrgTree ===
|
|
141
|
+
const ceoIcon = statuses['ceo'] === 'working' ? '\u25CF' : statuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
|
|
142
|
+
const isCeoSelected = flatRoles[selectedRoleIndex] === 'ceo';
|
|
143
|
+
const treeEntries = flattenTree(scopedTree);
|
|
144
|
+
|
|
145
|
+
const leftLines: Array<{ text: string; selected: boolean; working: boolean }> = [
|
|
146
|
+
{ text: `${ceoIcon} CEO`, selected: isCeoSelected, working: statuses['ceo'] === 'working' },
|
|
147
|
+
...treeEntries.map(e => ({
|
|
148
|
+
text: e.line,
|
|
149
|
+
selected: e.roleId === flatRoles[selectedRoleIndex],
|
|
150
|
+
working: e.status === 'working',
|
|
151
|
+
})),
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
// === Build right column: Stream/Info ===
|
|
155
|
+
const rightContentLines: string[] = [];
|
|
398
156
|
if (rightTab === 'stream') {
|
|
399
|
-
const maxEv = Math.
|
|
400
|
-
const
|
|
157
|
+
const maxEv = Math.max(5, termHeight - 10);
|
|
158
|
+
const filtered = selectedRoleId ? events.filter(e => e.roleId === selectedRoleId) : events;
|
|
159
|
+
const visible = filtered.slice(-maxEv);
|
|
401
160
|
for (const ev of visible) {
|
|
402
|
-
const line =
|
|
403
|
-
if (line)
|
|
161
|
+
const line = eventLine(ev);
|
|
162
|
+
if (line) rightContentLines.push(line.slice(0, rightWidth));
|
|
163
|
+
}
|
|
164
|
+
if (rightContentLines.length === 0) {
|
|
165
|
+
rightContentLines.push(waveId ? 'Waiting for events...' : 'No active stream. Type a directive to start.');
|
|
404
166
|
}
|
|
405
|
-
if (rightLines.length === 0) rightLines.push(waveId ? `Waiting... (${events.length} events, waveId=${waveId?.slice(-8)})` : 'No active stream.');
|
|
406
167
|
} else if (rightTab === 'info') {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
rightLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
|
|
168
|
+
rightContentLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
|
|
169
|
+
rightContentLines.push(`Directive: ${focusedWave?.directive?.slice(0, rightWidth - 12) || '(idle)'}`);
|
|
170
|
+
rightContentLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
|
|
411
171
|
} else {
|
|
412
|
-
|
|
172
|
+
rightContentLines.push('Press h to switch to Stream tab');
|
|
413
173
|
}
|
|
414
174
|
|
|
415
|
-
// Merge
|
|
416
|
-
const
|
|
417
|
-
const
|
|
175
|
+
// === Merge left + right, pad to fill terminal height ===
|
|
176
|
+
const headerLines = 3; // header + separator + org tree title
|
|
177
|
+
const footerLines = 2; // separator + keybindings
|
|
178
|
+
const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
|
|
179
|
+
const maxRows = Math.max(leftLines.length, rightContentLines.length, contentHeight);
|
|
180
|
+
|
|
181
|
+
const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
|
|
418
182
|
for (let i = 0; i < maxRows; i++) {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
183
|
+
const ll = leftLines[i];
|
|
184
|
+
rows.push({
|
|
185
|
+
left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
|
|
186
|
+
right: rightContentLines[i] ?? '',
|
|
187
|
+
leftSelected: ll?.selected ?? false,
|
|
188
|
+
leftWorking: ll?.working ?? false,
|
|
422
189
|
});
|
|
423
190
|
}
|
|
424
191
|
|
|
425
192
|
// Tab bar
|
|
426
|
-
const
|
|
427
|
-
|
|
193
|
+
const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
|
|
194
|
+
t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
|
|
195
|
+
).join(' ');
|
|
428
196
|
|
|
429
197
|
// Wave tabs
|
|
430
198
|
const waveTabs = waves.length > 1
|
|
431
199
|
? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
|
|
432
200
|
: '';
|
|
433
201
|
|
|
434
|
-
|
|
435
|
-
const
|
|
202
|
+
const sep = '\u2500'.repeat(Math.min(termCols, 160));
|
|
203
|
+
const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
|
|
436
204
|
|
|
437
205
|
return (
|
|
438
206
|
<Box flexDirection="column">
|
|
439
|
-
{/* Header
|
|
207
|
+
{/* Header */}
|
|
440
208
|
<Text>
|
|
441
209
|
<Text color="green" bold>{'W' + focusedWaveIndex}</Text>
|
|
442
210
|
<Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
|
|
443
211
|
<Text color="gray">{' \u2502 '}</Text>
|
|
444
212
|
<Text color="cyan" bold>{tabBar}</Text>
|
|
445
|
-
<Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '')}</Text>
|
|
213
|
+
<Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
|
|
214
|
+
<Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
|
|
446
215
|
</Text>
|
|
447
216
|
<Text color="gray">{sep}</Text>
|
|
448
217
|
|
|
449
|
-
{/*
|
|
450
|
-
{
|
|
218
|
+
{/* Content rows: left (OrgTree) │ right (Stream/Info) */}
|
|
219
|
+
{rows.map((row, i) => (
|
|
451
220
|
<Text key={i}>
|
|
452
|
-
<Text color={
|
|
221
|
+
<Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
|
|
453
222
|
<Text color="gray">{' \u2502 '}</Text>
|
|
454
|
-
<Text color="white">{
|
|
223
|
+
<Text color="white">{row.right}</Text>
|
|
455
224
|
</Text>
|
|
456
225
|
))}
|
|
457
226
|
|
|
458
|
-
{/*
|
|
227
|
+
{/* Footer */}
|
|
459
228
|
<Text color="gray">{sep}</Text>
|
|
460
|
-
|
|
461
|
-
<Text>
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
</Text>
|
|
465
|
-
|
|
466
|
-
<Text color="gray" dimColor>{'[h/l] tab [j/k] role [Esc] back'}</Text>
|
|
467
|
-
)}
|
|
229
|
+
<Text>
|
|
230
|
+
{waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
|
|
231
|
+
<Text color="gray" dimColor>{'[h/l] tab [j/k] role [Enter] filter '}</Text>
|
|
232
|
+
{waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
|
|
233
|
+
<Text color="gray" dimColor>{'[Esc] back'}</Text>
|
|
234
|
+
</Text>
|
|
468
235
|
</Box>
|
|
469
236
|
);
|
|
470
237
|
};
|