tycono 0.3.14-beta.15 → 0.3.14-beta.17
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 +0 -45
- package/src/tui/components/PanelMode.tsx +152 -361
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,49 +668,6 @@ 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
672
|
<Box flexDirection="column">
|
|
718
673
|
<Box flexDirection="column">
|
|
@@ -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,122 +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)
|
|
59
|
-
}
|
|
60
|
-
return lines;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function eventToOneLiner(event: SSEEvent): string | null {
|
|
64
|
-
const time = new Date(event.ts).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
65
|
-
const role = event.roleId.padEnd(12);
|
|
66
|
-
switch (event.type) {
|
|
67
|
-
case 'text': { const t = ((event.data.text as string) ?? '').trim(); return t ? `${time} ${role} ${t.slice(0, 100)}` : null; }
|
|
68
|
-
case 'thinking': { const t = ((event.data.text as string) ?? '').trim(); return t ? `${time} ${role} \uD83D\uDCAD ${t.slice(0, 80)}` : null; }
|
|
69
|
-
case 'tool:start': { const n = (event.data.name as string) ?? ''; return `${time} ${role} \u2192 ${n}`; }
|
|
70
|
-
case 'msg:start': return `${time} ${role} \u25B6 Started`;
|
|
71
|
-
case 'msg:done': return `${time} ${role} \u2713 Done`;
|
|
72
|
-
case 'msg:error': return `${time} ${role} \u2717 Error`;
|
|
73
|
-
case 'dispatch:start': return `${time} ${role} \u21D2 ${event.data.targetRole as string ?? ''}`;
|
|
74
|
-
case 'tool:result': return `${time} ${role} \u2190 ${(event.data.name as string) ?? 'done'}`;
|
|
75
|
-
case 'msg:awaiting_input': return `${time} ${role} ? awaiting input`;
|
|
76
|
-
default: return null; // skip internal: turn-complete, trace, heartbeat
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function getWaveScopedStatuses(
|
|
81
|
-
allSessions: SessionInfo[],
|
|
82
|
-
focusedWaveId: string | null,
|
|
83
|
-
): Record<string, string> {
|
|
84
|
-
if (!focusedWaveId) return {};
|
|
85
|
-
const statuses: Record<string, string> = {};
|
|
86
|
-
for (const s of allSessions) {
|
|
87
|
-
if (s.waveId !== focusedWaveId) continue;
|
|
88
|
-
if (s.status === 'active') statuses[s.roleId] = 'working';
|
|
89
|
-
else if (!statuses[s.roleId]) statuses[s.roleId] = 'done';
|
|
90
|
-
}
|
|
91
|
-
return statuses;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function findSessionForRole(
|
|
95
|
-
activeSessions: ActiveSessionInfo[],
|
|
96
|
-
allSessions: SessionInfo[],
|
|
97
|
-
roleId: string,
|
|
98
|
-
focusedWaveId: string | null,
|
|
99
|
-
): ActiveSessionInfo | null {
|
|
100
|
-
if (focusedWaveId) {
|
|
101
|
-
const waveSes = allSessions.find(s => s.waveId === focusedWaveId && s.roleId === roleId && s.status === 'active');
|
|
102
|
-
if (waveSes) return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
|
|
103
|
-
}
|
|
104
|
-
return activeSessions.find(s => s.roleId === roleId && s.status === 'active') ?? null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function elapsed(startedAt: string): string {
|
|
108
|
-
const ms = Date.now() - new Date(startedAt).getTime();
|
|
109
|
-
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
110
|
-
if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
111
|
-
return `${Math.floor(ms / 3600_000)}h`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Scan COMPANY_ROOT for .md files (cached) */
|
|
115
|
-
let mdFileCache: { root: string; files: string[] } | null = null;
|
|
116
|
-
function scanMdFiles(companyRoot: string): string[] {
|
|
117
|
-
if (mdFileCache && mdFileCache.root === companyRoot) return mdFileCache.files;
|
|
118
|
-
const results: string[] = [];
|
|
119
|
-
const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude']);
|
|
120
|
-
function walk(dir: string, depth: number) {
|
|
121
|
-
if (depth > 3) return; // Don't go too deep
|
|
122
|
-
try {
|
|
123
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
124
|
-
if (skip.has(entry.name)) continue;
|
|
125
|
-
const full = path.join(dir, entry.name);
|
|
126
|
-
if (entry.isDirectory()) {
|
|
127
|
-
walk(full, depth + 1);
|
|
128
|
-
} else if (entry.name.endsWith('.md')) {
|
|
129
|
-
results.push(full);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
} catch { /* permission error etc */ }
|
|
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]));
|
|
133
62
|
}
|
|
134
|
-
|
|
135
|
-
mdFileCache = { root: companyRoot, files: results };
|
|
136
|
-
return results;
|
|
63
|
+
return result;
|
|
137
64
|
}
|
|
138
65
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
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`;
|
|
81
|
+
default: return null;
|
|
150
82
|
}
|
|
151
|
-
return Array.from(files);
|
|
152
83
|
}
|
|
153
84
|
|
|
154
|
-
|
|
155
|
-
const fileCache = new Map<string, string[]>();
|
|
156
|
-
function readFilePreview(filePath: string, maxLines: number): string[] {
|
|
157
|
-
const cached = fileCache.get(filePath);
|
|
158
|
-
if (cached) return cached;
|
|
159
|
-
try {
|
|
160
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
161
|
-
const lines = content.split('\n').slice(0, maxLines);
|
|
162
|
-
fileCache.set(filePath, lines);
|
|
163
|
-
// Evict old entries
|
|
164
|
-
if (fileCache.size > 5) {
|
|
165
|
-
const first = fileCache.keys().next().value;
|
|
166
|
-
if (first) fileCache.delete(first);
|
|
167
|
-
}
|
|
168
|
-
return lines;
|
|
169
|
-
} catch {
|
|
170
|
-
return ['(cannot read file)'];
|
|
171
|
-
}
|
|
172
|
-
}
|
|
85
|
+
/* ─── Component ─── */
|
|
173
86
|
|
|
174
87
|
const PanelModeInner: React.FC<PanelModeProps> = ({
|
|
175
88
|
tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
|
|
@@ -178,295 +91,173 @@ const PanelModeInner: React.FC<PanelModeProps> = ({
|
|
|
178
91
|
}) => {
|
|
179
92
|
const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
|
|
180
93
|
const [rightTab, setRightTab] = useState<RightTab>('stream');
|
|
181
|
-
const [docsFilter, setDocsFilter] = useState<DocsFilter>('all');
|
|
182
|
-
const [docsIndex, setDocsIndex] = useState(0);
|
|
183
|
-
const [docsScroll, setDocsScroll] = useState(0);
|
|
184
94
|
|
|
185
95
|
useEffect(() => {
|
|
186
|
-
const
|
|
187
|
-
process.stdout.on('resize',
|
|
188
|
-
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); };
|
|
189
99
|
}, []);
|
|
190
100
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
() => getWaveScopedStatuses(allSessions, focusedWaveId),
|
|
197
|
-
[allSessions, focusedWaveId],
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
const waveScopedTree = useMemo(() => {
|
|
201
|
-
function scopeNode(node: OrgNode): OrgNode {
|
|
202
|
-
return {
|
|
203
|
-
...node,
|
|
204
|
-
status: waveScopedStatuses[node.role.id] ?? 'idle',
|
|
205
|
-
children: node.children.map(scopeNode),
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
return tree.map(scopeNode);
|
|
209
|
-
}, [tree, waveScopedStatuses]);
|
|
210
|
-
|
|
211
|
-
// Wave files (from SSE events) — only compute when needed
|
|
212
|
-
const waveFileSet = useMemo(() => {
|
|
213
|
-
if (rightTab !== 'docs' && rightTab !== 'info') return new Set<string>();
|
|
214
|
-
return new Set(extractWaveFiles(events));
|
|
215
|
-
}, [rightTab === 'docs' || rightTab === 'info' ? events.length : 0, rightTab]);
|
|
216
|
-
|
|
217
|
-
// Build docs list from filesystem scan + wave files
|
|
218
|
-
const docsList = useMemo(() => {
|
|
219
|
-
if (rightTab !== 'docs') return [];
|
|
220
|
-
|
|
221
|
-
interface DocEntry { path: string; title: string; isWave: boolean; }
|
|
222
|
-
const entries: DocEntry[] = [];
|
|
223
|
-
|
|
224
|
-
// Scan all .md files from COMPANY_ROOT
|
|
225
|
-
const allMdFiles = companyRoot ? scanMdFiles(companyRoot) : [];
|
|
226
|
-
|
|
227
|
-
for (const filePath of allMdFiles) {
|
|
228
|
-
const rel = filePath.replace(companyRoot + '/', '');
|
|
229
|
-
const isWave = waveFileSet.has(filePath);
|
|
230
|
-
const isKb = rel.startsWith('knowledge/');
|
|
231
|
-
const isProject = rel.startsWith('projects/');
|
|
232
|
-
|
|
233
|
-
if (docsFilter === 'wave' && !isWave) continue;
|
|
234
|
-
if (docsFilter === 'kb' && !isKb) continue;
|
|
235
|
-
if (docsFilter === 'projects' && !isProject) continue;
|
|
236
|
-
|
|
237
|
-
entries.push({ path: filePath, title: rel, isWave });
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Wave-only files not already in list (e.g. code files written by agents)
|
|
241
|
-
for (const f of waveFileSet) {
|
|
242
|
-
if (!entries.some(e => e.path === f)) {
|
|
243
|
-
if (docsFilter === 'kb' || docsFilter === 'projects') continue;
|
|
244
|
-
entries.push({ path: f, title: f.split('/').pop() || f, isWave: true });
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Sort: wave files first, then alphabetical
|
|
249
|
-
entries.sort((a, b) => {
|
|
250
|
-
if (a.isWave && !b.isWave) return -1;
|
|
251
|
-
if (!a.isWave && b.isWave) return 1;
|
|
252
|
-
return a.title.localeCompare(b.title);
|
|
253
|
-
});
|
|
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]);
|
|
254
106
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const selectedDoc = docsList[docsIndex] ?? null;
|
|
259
|
-
const filePreview = useMemo(() => {
|
|
260
|
-
if (!selectedDoc || rightTab !== 'docs') return [];
|
|
261
|
-
return readFilePreview(selectedDoc.path, 60);
|
|
262
|
-
}, [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;
|
|
263
110
|
|
|
111
|
+
// Key handling
|
|
264
112
|
useInput((input, key) => {
|
|
265
113
|
if (key.escape) { onEscape(); return; }
|
|
266
|
-
|
|
267
|
-
// h/l: switch right panel tab
|
|
268
|
-
if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
|
|
114
|
+
if (input === 'h' || key.leftArrow) {
|
|
269
115
|
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
270
116
|
const idx = tabs.indexOf(rightTab);
|
|
271
|
-
if (idx > 0)
|
|
117
|
+
if (idx > 0) setRightTab(tabs[idx - 1]);
|
|
272
118
|
return;
|
|
273
119
|
}
|
|
274
|
-
if (input === 'l' ||
|
|
120
|
+
if (input === 'l' || key.rightArrow) {
|
|
275
121
|
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
276
122
|
const idx = tabs.indexOf(rightTab);
|
|
277
|
-
if (idx < tabs.length - 1)
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// j/k: context-dependent
|
|
282
|
-
if (key.upArrow || input === 'k') {
|
|
283
|
-
if (rightTab === 'docs') {
|
|
284
|
-
if (docsScroll > 0) {
|
|
285
|
-
setDocsScroll(s => Math.max(0, s - 3));
|
|
286
|
-
} else {
|
|
287
|
-
setDocsIndex(i => Math.max(0, i - 1));
|
|
288
|
-
}
|
|
289
|
-
} else if (rightTab === 'stream') {
|
|
290
|
-
onMove('up');
|
|
291
|
-
}
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
if (key.downArrow || input === 'j') {
|
|
295
|
-
if (rightTab === 'docs') {
|
|
296
|
-
if (docsScroll > 0) {
|
|
297
|
-
setDocsScroll(s => s + 3);
|
|
298
|
-
} else {
|
|
299
|
-
setDocsIndex(i => Math.min(docsList.length - 1, i + 1));
|
|
300
|
-
}
|
|
301
|
-
} else if (rightTab === 'stream') {
|
|
302
|
-
onMove('down');
|
|
303
|
-
}
|
|
123
|
+
if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
|
|
304
124
|
return;
|
|
305
125
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
setDocsIndex(0);
|
|
314
|
-
setDocsScroll(0);
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Tab key for cycling docs files
|
|
320
|
-
if (key.tab && rightTab === 'docs') {
|
|
321
|
-
setDocsIndex(i => (i + 1) % Math.max(1, docsList.length));
|
|
322
|
-
setDocsScroll(0);
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Enter
|
|
327
|
-
if (key.return) {
|
|
328
|
-
if (rightTab === 'docs' && selectedDoc) {
|
|
329
|
-
const editor = process.env.EDITOR || 'vim';
|
|
330
|
-
try {
|
|
331
|
-
execSync(`${editor} "${selectedDoc.path}"`, { stdio: 'inherit' });
|
|
332
|
-
} catch { /* user quit editor */ }
|
|
333
|
-
fileCache.delete(selectedDoc.path); // Invalidate cache after edit
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
if (rightTab === 'stream') {
|
|
337
|
-
onSelect();
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// 1-9: wave switch (only in stream/info tabs — docs uses 1-4 for filters)
|
|
343
|
-
if (rightTab !== 'docs') {
|
|
344
|
-
const num = parseInt(input, 10);
|
|
345
|
-
if (num >= 1 && num <= 9 && num <= waves.length) {
|
|
346
|
-
onFocusWave(waves[num - 1].waveId);
|
|
347
|
-
}
|
|
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);
|
|
348
133
|
}
|
|
349
134
|
});
|
|
350
135
|
|
|
351
|
-
// Filter events for selected role
|
|
352
|
-
const roleEvents = selectedRoleId
|
|
353
|
-
? events.filter((e) => e.roleId === selectedRoleId)
|
|
354
|
-
: events;
|
|
355
|
-
|
|
356
|
-
const roleLabel = selectedRoleId
|
|
357
|
-
? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
|
|
358
|
-
: 'All';
|
|
359
|
-
|
|
360
|
-
const selectedSession = selectedRoleId
|
|
361
|
-
? findSessionForRole(activeSessions, allSessions, selectedRoleId, focusedWaveId)
|
|
362
|
-
: null;
|
|
363
|
-
|
|
364
|
-
const focusedWave = waves.find(w => w.waveId === focusedWaveId);
|
|
365
|
-
const focusedWaveIndex = focusedWaveId
|
|
366
|
-
? waves.findIndex(w => w.waveId === focusedWaveId) + 1
|
|
367
|
-
: 0;
|
|
368
|
-
|
|
369
|
-
const waveSessionCount = focusedWaveId
|
|
370
|
-
? allSessions.filter(s => s.waveId === focusedWaveId).length
|
|
371
|
-
: 0;
|
|
372
|
-
|
|
373
|
-
// Read preset from wave file on disk
|
|
374
|
-
const wavePreset = useMemo(() => {
|
|
375
|
-
if (!focusedWaveId || !companyRoot) return null;
|
|
376
|
-
try {
|
|
377
|
-
const wavePath = path.join(companyRoot, 'operations', 'waves', `${focusedWaveId}.json`);
|
|
378
|
-
if (fs.existsSync(wavePath)) {
|
|
379
|
-
const data = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
|
|
380
|
-
return data.preset as string | undefined;
|
|
381
|
-
}
|
|
382
|
-
} catch { /* ignore */ }
|
|
383
|
-
return null;
|
|
384
|
-
}, [focusedWaveId, companyRoot]);
|
|
385
|
-
|
|
386
136
|
const leftWidth = 28;
|
|
387
137
|
const termCols = process.stdout.columns || 120;
|
|
388
|
-
const rightWidth =
|
|
389
|
-
|
|
390
|
-
// === Build
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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[] = [];
|
|
400
156
|
if (rightTab === 'stream') {
|
|
401
|
-
const maxEv = Math.
|
|
402
|
-
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);
|
|
403
160
|
for (const ev of visible) {
|
|
404
|
-
const line =
|
|
405
|
-
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... (total ${events.length} events)` : 'No active stream. Type a directive to start.');
|
|
406
166
|
}
|
|
407
|
-
if (rightLines.length === 0) rightLines.push(waveId ? `Waiting... (${events.length} events, waveId=${waveId?.slice(-8)})` : 'No active stream.');
|
|
408
167
|
} else if (rightTab === 'info') {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
} else {
|
|
414
|
-
|
|
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}`);
|
|
171
|
+
rightContentLines.push(`Stream: ${streamStatus}`);
|
|
172
|
+
} else if (rightTab === 'docs') {
|
|
173
|
+
// Docs: scan .md files from COMPANY_ROOT
|
|
174
|
+
try {
|
|
175
|
+
const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude', '.obsidian']);
|
|
176
|
+
const mdFiles: string[] = [];
|
|
177
|
+
const walk = (dir: string, depth: number) => {
|
|
178
|
+
if (depth > 3 || mdFiles.length > 50) return;
|
|
179
|
+
try {
|
|
180
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
181
|
+
if (skip.has(e.name)) continue;
|
|
182
|
+
const full = path.join(dir, e.name);
|
|
183
|
+
if (e.isDirectory()) walk(full, depth + 1);
|
|
184
|
+
else if (e.name.endsWith('.md')) mdFiles.push(full.replace(companyRoot + '/', ''));
|
|
185
|
+
}
|
|
186
|
+
} catch {}
|
|
187
|
+
};
|
|
188
|
+
walk(companyRoot, 0);
|
|
189
|
+
mdFiles.sort();
|
|
190
|
+
const maxDocs = Math.max(5, termHeight - 12);
|
|
191
|
+
rightContentLines.push(`${mdFiles.length} documents`);
|
|
192
|
+
for (const f of mdFiles.slice(0, maxDocs)) {
|
|
193
|
+
rightContentLines.push(` ${f.slice(0, rightWidth - 4)}`);
|
|
194
|
+
}
|
|
195
|
+
if (mdFiles.length > maxDocs) rightContentLines.push(` ... +${mdFiles.length - maxDocs} more`);
|
|
196
|
+
} catch {
|
|
197
|
+
rightContentLines.push('Cannot scan documents');
|
|
198
|
+
}
|
|
415
199
|
}
|
|
416
200
|
|
|
417
|
-
// Merge
|
|
418
|
-
const
|
|
419
|
-
const
|
|
201
|
+
// === Merge left + right, pad to fill terminal height ===
|
|
202
|
+
const headerLines = 3; // header + separator + org tree title
|
|
203
|
+
const footerLines = 2; // separator + keybindings
|
|
204
|
+
const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
|
|
205
|
+
const maxRows = Math.max(leftLines.length, rightContentLines.length, contentHeight);
|
|
206
|
+
|
|
207
|
+
const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
|
|
420
208
|
for (let i = 0; i < maxRows; i++) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
209
|
+
const ll = leftLines[i];
|
|
210
|
+
rows.push({
|
|
211
|
+
left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
|
|
212
|
+
right: rightContentLines[i] ?? '',
|
|
213
|
+
leftSelected: ll?.selected ?? false,
|
|
214
|
+
leftWorking: ll?.working ?? false,
|
|
424
215
|
});
|
|
425
216
|
}
|
|
426
217
|
|
|
427
218
|
// Tab bar
|
|
428
|
-
const
|
|
429
|
-
|
|
219
|
+
const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
|
|
220
|
+
t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
|
|
221
|
+
).join(' ');
|
|
430
222
|
|
|
431
223
|
// Wave tabs
|
|
432
224
|
const waveTabs = waves.length > 1
|
|
433
225
|
? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
|
|
434
226
|
: '';
|
|
435
227
|
|
|
436
|
-
|
|
437
|
-
const
|
|
228
|
+
const sep = '\u2500'.repeat(Math.min(termCols, 160));
|
|
229
|
+
const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
|
|
438
230
|
|
|
439
231
|
return (
|
|
440
232
|
<Box flexDirection="column">
|
|
441
|
-
{/* Header
|
|
233
|
+
{/* Header */}
|
|
442
234
|
<Text>
|
|
443
235
|
<Text color="green" bold>{'W' + focusedWaveIndex}</Text>
|
|
444
236
|
<Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
|
|
445
237
|
<Text color="gray">{' \u2502 '}</Text>
|
|
446
238
|
<Text color="cyan" bold>{tabBar}</Text>
|
|
447
|
-
<Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '')}</Text>
|
|
239
|
+
<Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
|
|
240
|
+
<Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
|
|
448
241
|
</Text>
|
|
449
242
|
<Text color="gray">{sep}</Text>
|
|
450
243
|
|
|
451
|
-
{/*
|
|
452
|
-
{
|
|
244
|
+
{/* Content rows: left (OrgTree) │ right (Stream/Info) */}
|
|
245
|
+
{rows.map((row, i) => (
|
|
453
246
|
<Text key={i}>
|
|
454
|
-
<Text color={
|
|
247
|
+
<Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
|
|
455
248
|
<Text color="gray">{' \u2502 '}</Text>
|
|
456
|
-
<Text color="white">{
|
|
249
|
+
<Text color="white">{row.right}</Text>
|
|
457
250
|
</Text>
|
|
458
251
|
))}
|
|
459
252
|
|
|
460
|
-
{/*
|
|
253
|
+
{/* Footer */}
|
|
461
254
|
<Text color="gray">{sep}</Text>
|
|
462
|
-
|
|
463
|
-
<Text>
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
</Text>
|
|
467
|
-
|
|
468
|
-
<Text color="gray" dimColor>{'[h/l] tab [j/k] role [Esc] back'}</Text>
|
|
469
|
-
)}
|
|
255
|
+
<Text>
|
|
256
|
+
{waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
|
|
257
|
+
<Text color="gray" dimColor>{'[h/l] tab [j/k] role [Enter] filter '}</Text>
|
|
258
|
+
{waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
|
|
259
|
+
<Text color="gray" dimColor>{'[Esc] back'}</Text>
|
|
260
|
+
</Text>
|
|
470
261
|
</Box>
|
|
471
262
|
);
|
|
472
263
|
};
|