tycono 0.3.14-beta.9 → 0.3.14
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 +2 -2
- package/src/tui/app.tsx +3 -46
- package/src/tui/components/CommandMode.tsx +13 -27
- package/src/tui/components/OrgTree.tsx +15 -82
- package/src/tui/components/PanelMode.tsx +226 -472
- package/src/tui/components/StreamView.tsx +45 -113
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tycono",
|
|
3
|
-
"version": "0.3.14
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "Build an AI company. Watch them work.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"cors": "^2.8.5",
|
|
36
36
|
"dotenv": "^16.4.7",
|
|
37
37
|
"express": "^5.0.1",
|
|
38
|
-
"glob": "^
|
|
38
|
+
"glob": "^13.0.6",
|
|
39
39
|
"gray-matter": "^4.0.3",
|
|
40
40
|
"ink": "^5.2.1",
|
|
41
41
|
"ink-select-input": "^6.2.0",
|
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';
|
|
@@ -632,6 +630,8 @@ export const App: React.FC = () => {
|
|
|
632
630
|
return;
|
|
633
631
|
}
|
|
634
632
|
if (mode === 'command' && key.tab) {
|
|
633
|
+
// Clear terminal before Panel Mode (removes Command Mode scrollback)
|
|
634
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
635
635
|
setMode('panel');
|
|
636
636
|
}
|
|
637
637
|
});
|
|
@@ -670,52 +670,9 @@ export const App: React.FC = () => {
|
|
|
670
670
|
// Command Mode: scrollable terminal (no fullscreen)
|
|
671
671
|
// Panel Mode: fullscreen (intentional — like vim for inspection)
|
|
672
672
|
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
673
|
return (
|
|
717
674
|
<Box flexDirection="column">
|
|
718
|
-
<Box
|
|
675
|
+
<Box flexDirection="column">
|
|
719
676
|
<PanelMode
|
|
720
677
|
tree={orgTree}
|
|
721
678
|
flatRoles={flatRoleIds}
|
|
@@ -83,16 +83,8 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
case 'thinking': {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
id: ++lineCounter,
|
|
90
|
-
prefix: isSupervisor ? undefined : event.roleId,
|
|
91
|
-
prefixColor: roleColor,
|
|
92
|
-
text: `\uD83D\uDCAD ${text}`,
|
|
93
|
-
color: 'gray',
|
|
94
|
-
indent: !isSupervisor,
|
|
95
|
-
};
|
|
86
|
+
// Hide thinking by default — internal noise for user
|
|
87
|
+
return null;
|
|
96
88
|
}
|
|
97
89
|
|
|
98
90
|
case 'dispatch:start': {
|
|
@@ -130,17 +122,18 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
|
|
|
130
122
|
|
|
131
123
|
case 'tool:start': {
|
|
132
124
|
const toolName = (event.data.name as string) ?? 'tool';
|
|
125
|
+
// Only show Write/Edit (file changes) + Bash (commands). Hide Read/Grep/Glob (noise).
|
|
126
|
+
const isWrite = ['Write', 'Edit', 'NotebookEdit'].includes(toolName);
|
|
127
|
+
if (!isWrite) return null; // Only show file writes — hide Read/Grep/Glob/Bash
|
|
128
|
+
|
|
133
129
|
const input = event.data.input;
|
|
134
130
|
let detail = '';
|
|
135
131
|
if (input && typeof input === 'object') {
|
|
136
132
|
const inp = input as Record<string, unknown>;
|
|
137
|
-
if (inp.file_path) detail = ` ${String(inp.file_path)}`;
|
|
138
|
-
else if (inp.command) detail = ` ${String(inp.command).slice(0,
|
|
139
|
-
else if (inp.
|
|
140
|
-
else if (inp.description) detail = ` ${String(inp.description).slice(0, 60)}`;
|
|
133
|
+
if (inp.file_path) detail = ` ${String(inp.file_path).split('/').slice(-2).join('/')}`;
|
|
134
|
+
else if (inp.command) detail = ` ${String(inp.command).slice(0, 60)}`;
|
|
135
|
+
else if (inp.description) detail = ` ${String(inp.description).slice(0, 40)}`;
|
|
141
136
|
}
|
|
142
|
-
// Highlight file writes
|
|
143
|
-
const isWrite = ['Write', 'Edit'].includes(toolName);
|
|
144
137
|
return {
|
|
145
138
|
id: ++lineCounter,
|
|
146
139
|
prefix: isSupervisor ? undefined : event.roleId,
|
|
@@ -152,15 +145,8 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
|
|
|
152
145
|
}
|
|
153
146
|
|
|
154
147
|
case 'tool:result': {
|
|
155
|
-
|
|
156
|
-
return
|
|
157
|
-
id: ++lineCounter,
|
|
158
|
-
prefix: isSupervisor ? undefined : event.roleId,
|
|
159
|
-
prefixColor: roleColor,
|
|
160
|
-
text: ` \u2190 ${toolName} done`,
|
|
161
|
-
color: 'gray',
|
|
162
|
-
indent: !isSupervisor,
|
|
163
|
-
};
|
|
148
|
+
// Hide tool results — tool:start is sufficient
|
|
149
|
+
return null;
|
|
164
150
|
}
|
|
165
151
|
|
|
166
152
|
case 'msg:start': {
|
|
@@ -421,10 +407,10 @@ export const CommandMode: React.FC<CommandModeProps> = ({
|
|
|
421
407
|
const handleSubmit = useCallback((value: string) => {
|
|
422
408
|
const trimmed = value.trim();
|
|
423
409
|
if (trimmed) {
|
|
424
|
-
// Show user input
|
|
410
|
+
// Show user input with visual separator for emphasis
|
|
425
411
|
setUserInputs(prev => [...prev.slice(-10), {
|
|
426
412
|
id: ++lineCounter,
|
|
427
|
-
text:
|
|
413
|
+
text: `\u2501\u2501 > ${trimmed}`,
|
|
428
414
|
color: 'green',
|
|
429
415
|
}]);
|
|
430
416
|
onSubmit(trimmed);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OrgTree — left panel showing organization hierarchy
|
|
3
|
-
*
|
|
2
|
+
* OrgTree — left panel showing organization hierarchy
|
|
3
|
+
* Simplified to single Text render to prevent yoga OOM on wide terminals
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import React from 'react';
|
|
@@ -16,103 +16,36 @@ interface OrgTreeProps {
|
|
|
16
16
|
ceoStatus?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
case 'working':
|
|
22
|
-
case 'streaming':
|
|
23
|
-
return 'green';
|
|
24
|
-
case 'done':
|
|
25
|
-
return 'gray';
|
|
26
|
-
case 'error':
|
|
27
|
-
return 'red';
|
|
28
|
-
case 'awaiting_input':
|
|
29
|
-
return 'yellow';
|
|
30
|
-
default:
|
|
31
|
-
return 'gray';
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface FlatEntry {
|
|
36
|
-
roleId: string;
|
|
37
|
-
level: string;
|
|
38
|
-
status: string;
|
|
39
|
-
prefix: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function flattenTree(nodes: OrgNode[], prefix: string = '', isLast: boolean[] = []): FlatEntry[] {
|
|
43
|
-
const result: FlatEntry[] = [];
|
|
44
|
-
|
|
19
|
+
function flattenTree(nodes: OrgNode[], isLast: boolean[] = []): Array<{ roleId: string; status: string; line: string }> {
|
|
20
|
+
const result: Array<{ roleId: string; status: string; line: string }> = [];
|
|
45
21
|
for (let i = 0; i < nodes.length; i++) {
|
|
46
22
|
const node = nodes[i];
|
|
47
23
|
const last = i === nodes.length - 1;
|
|
48
|
-
|
|
49
|
-
let linePrefix = '';
|
|
24
|
+
let prefix = '';
|
|
50
25
|
for (let j = 0; j < isLast.length; j++) {
|
|
51
|
-
|
|
26
|
+
prefix += isLast[j] ? ' ' : '\u2502 ';
|
|
52
27
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
result.push({
|
|
58
|
-
roleId: node.role.id,
|
|
59
|
-
level: node.role.level,
|
|
60
|
-
status: node.status,
|
|
61
|
-
prefix: linePrefix,
|
|
62
|
-
});
|
|
63
|
-
|
|
28
|
+
prefix += last ? '\u2514\u2500 ' : '\u251C\u2500 ';
|
|
29
|
+
const icon = statusIcon(node.status);
|
|
30
|
+
result.push({ roleId: node.role.id, status: node.status, line: `${prefix}${icon} ${node.role.id}` });
|
|
64
31
|
if (node.children.length > 0) {
|
|
65
|
-
result.push(...flattenTree(node.children,
|
|
32
|
+
result.push(...flattenTree(node.children, [...isLast, last]));
|
|
66
33
|
}
|
|
67
34
|
}
|
|
68
|
-
|
|
69
35
|
return result;
|
|
70
36
|
}
|
|
71
37
|
|
|
72
38
|
export const OrgTree: React.FC<OrgTreeProps> = React.memo(({ tree, focused, selectedIndex, flatRoles, ceoStatus }) => {
|
|
73
|
-
const entries = flattenTree(tree);
|
|
74
|
-
const isCeoSelected = focused && flatRoles[selectedIndex] === 'ceo';
|
|
75
39
|
const ceoIcon = statusIcon(ceoStatus ?? 'idle');
|
|
76
|
-
const
|
|
40
|
+
const entries = flattenTree(tree);
|
|
41
|
+
|
|
42
|
+
// Render entire tree as single Text block (1 yoga node instead of 50+)
|
|
43
|
+
const lines = [`${ceoIcon} CEO`, ...entries.map(e => e.line)];
|
|
77
44
|
|
|
78
45
|
return (
|
|
79
46
|
<Box flexDirection="column" paddingX={1}>
|
|
80
47
|
<Text bold color={focused ? 'cyan' : 'gray'}>{'\u2500\u2500 Org Tree \u2500\u2500'}</Text>
|
|
81
|
-
<
|
|
82
|
-
<Text color={ceoColor} bold={ceoStatus === 'working'}>{ceoIcon} </Text>
|
|
83
|
-
<Text
|
|
84
|
-
color={isCeoSelected ? 'cyan' : 'yellow'}
|
|
85
|
-
bold={isCeoSelected}
|
|
86
|
-
inverse={isCeoSelected}
|
|
87
|
-
>
|
|
88
|
-
CEO
|
|
89
|
-
</Text>
|
|
90
|
-
</Box>
|
|
91
|
-
{entries.map((entry, i) => {
|
|
92
|
-
const isSelected = focused && flatRoles[selectedIndex] === entry.roleId;
|
|
93
|
-
const icon = statusIcon(entry.status);
|
|
94
|
-
const color = statusColor(entry.status);
|
|
95
|
-
|
|
96
|
-
return (
|
|
97
|
-
<Box key={entry.roleId + '-' + i}>
|
|
98
|
-
<Text color="gray">{entry.prefix}</Text>
|
|
99
|
-
<Text
|
|
100
|
-
color={color}
|
|
101
|
-
bold={entry.status === 'working'}
|
|
102
|
-
>
|
|
103
|
-
{icon}
|
|
104
|
-
</Text>
|
|
105
|
-
<Text> </Text>
|
|
106
|
-
<Text
|
|
107
|
-
color={isSelected ? 'cyan' : 'white'}
|
|
108
|
-
bold={isSelected}
|
|
109
|
-
inverse={isSelected}
|
|
110
|
-
>
|
|
111
|
-
{entry.roleId}
|
|
112
|
-
</Text>
|
|
113
|
-
</Box>
|
|
114
|
-
);
|
|
115
|
-
})}
|
|
48
|
+
<Text color="white">{'\n' + lines.join('\n')}</Text>
|
|
116
49
|
</Box>
|
|
117
50
|
);
|
|
118
51
|
});
|
|
@@ -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,61 @@ 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
|
-
|
|
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
|
-
|
|
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,221 @@ 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);
|
|
152
103
|
|
|
153
104
|
useEffect(() => {
|
|
154
|
-
const
|
|
155
|
-
process.stdout.on('resize',
|
|
156
|
-
return () => { process.stdout.off('resize',
|
|
105
|
+
const fn = () => setTermHeight(process.stdout.rows || 30);
|
|
106
|
+
process.stdout.on('resize', fn);
|
|
107
|
+
return () => { process.stdout.off('resize', fn); };
|
|
157
108
|
}, []);
|
|
158
109
|
|
|
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]);
|
|
184
|
-
|
|
185
|
-
// Build docs list from filesystem scan + wave files
|
|
186
|
-
const docsList = useMemo(() => {
|
|
187
|
-
if (rightTab !== 'docs') return [];
|
|
110
|
+
const statuses = useMemo(() => getWaveScopedStatuses(allSessions, focusedWaveId), [allSessions, focusedWaveId]);
|
|
111
|
+
const scopedTree = useMemo(() => {
|
|
112
|
+
const scope = (n: OrgNode): OrgNode => ({ ...n, status: statuses[n.role.id] ?? 'idle', children: n.children.map(scope) });
|
|
113
|
+
return tree.map(scope);
|
|
114
|
+
}, [tree, statuses]);
|
|
188
115
|
|
|
189
|
-
|
|
190
|
-
|
|
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]);
|
|
116
|
+
const focusedWave = waves.find(w => w.waveId === focusedWaveId);
|
|
117
|
+
const focusedWaveIndex = focusedWaveId ? waves.findIndex(w => w.waveId === focusedWaveId) + 1 : 0;
|
|
118
|
+
const waveSessionCount = focusedWaveId ? allSessions.filter(s => s.waveId === focusedWaveId).length : 0;
|
|
231
119
|
|
|
120
|
+
// Key handling
|
|
232
121
|
useInput((input, key) => {
|
|
233
122
|
if (key.escape) { onEscape(); return; }
|
|
234
|
-
|
|
235
|
-
// h/l: switch right panel tab
|
|
236
|
-
if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
|
|
123
|
+
if (input === 'h' || key.leftArrow) {
|
|
237
124
|
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
238
125
|
const idx = tabs.indexOf(rightTab);
|
|
239
|
-
if (idx > 0)
|
|
126
|
+
if (idx > 0) setRightTab(tabs[idx - 1]);
|
|
240
127
|
return;
|
|
241
128
|
}
|
|
242
|
-
if (input === 'l' ||
|
|
129
|
+
if (input === 'l' || key.rightArrow) {
|
|
243
130
|
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
244
131
|
const idx = tabs.indexOf(rightTab);
|
|
245
|
-
if (idx < tabs.length - 1)
|
|
132
|
+
if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
|
|
246
133
|
return;
|
|
247
134
|
}
|
|
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
|
-
}
|
|
135
|
+
// j/k context-dependent
|
|
136
|
+
if (input === 'k' || key.upArrow) {
|
|
137
|
+
if (rightTab === 'docs') { setDocsIndex(i => Math.max(0, i - 1)); }
|
|
138
|
+
else { onMove('up'); }
|
|
260
139
|
return;
|
|
261
140
|
}
|
|
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
|
-
}
|
|
141
|
+
if (input === 'j' || key.downArrow) {
|
|
142
|
+
if (rightTab === 'docs') { setDocsIndex(i => i + 1); } // capped later by docsList length
|
|
143
|
+
else { onMove('down'); }
|
|
272
144
|
return;
|
|
273
145
|
}
|
|
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
146
|
if (key.return) {
|
|
296
|
-
if (rightTab === 'docs' &&
|
|
297
|
-
const editor = process.env.EDITOR || 'vim';
|
|
147
|
+
if (rightTab === 'docs' && selectedDocPath) {
|
|
298
148
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
if (rightTab === 'stream') {
|
|
149
|
+
const editor = process.env.EDITOR || 'vim';
|
|
150
|
+
execSync(`${editor} "${selectedDocPath}"`, { stdio: 'inherit' });
|
|
151
|
+
} catch { /* ignore */ }
|
|
152
|
+
} else {
|
|
305
153
|
onSelect();
|
|
306
|
-
return;
|
|
307
154
|
}
|
|
155
|
+
return;
|
|
308
156
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
if (num >= 1 && num <= 9 && num <= waves.length) {
|
|
314
|
-
onFocusWave(waves[num - 1].waveId);
|
|
315
|
-
}
|
|
157
|
+
// Wave switch 1-9
|
|
158
|
+
const num = parseInt(input, 10);
|
|
159
|
+
if (num >= 1 && num <= 9 && num <= waves.length) {
|
|
160
|
+
onFocusWave(waves[num - 1].waveId);
|
|
316
161
|
}
|
|
317
162
|
});
|
|
318
163
|
|
|
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
|
-
|
|
164
|
+
const leftWidth = 28;
|
|
165
|
+
const termCols = process.stdout.columns || 120;
|
|
166
|
+
const rightWidth = termCols - leftWidth - 3;
|
|
167
|
+
const headerLines = 2;
|
|
168
|
+
const footerLines = 3;
|
|
169
|
+
const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
|
|
170
|
+
|
|
171
|
+
// === Build left column: OrgTree ===
|
|
172
|
+
const ceoIcon = statuses['ceo'] === 'working' ? '\u25CF' : statuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
|
|
173
|
+
const isCeoSelected = flatRoles[selectedRoleIndex] === 'ceo';
|
|
174
|
+
const treeEntries = flattenTree(scopedTree);
|
|
175
|
+
|
|
176
|
+
const leftLines: Array<{ text: string; selected: boolean; working: boolean }> = [
|
|
177
|
+
{ text: `${ceoIcon} CEO`, selected: isCeoSelected, working: statuses['ceo'] === 'working' },
|
|
178
|
+
...treeEntries.map(e => ({
|
|
179
|
+
text: e.line,
|
|
180
|
+
selected: e.roleId === flatRoles[selectedRoleIndex],
|
|
181
|
+
working: e.status === 'working',
|
|
182
|
+
})),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
// Derive selectedRoleId from index (more reliable than prop — avoids sync issues)
|
|
186
|
+
const activeRoleId = flatRoles[selectedRoleIndex] ?? null;
|
|
187
|
+
|
|
188
|
+
// === Build right column: Stream/Info/Docs ===
|
|
189
|
+
const rightContentLines: string[] = [];
|
|
190
|
+
let selectedDocPath: string | null = null;
|
|
191
|
+
if (rightTab === 'stream') {
|
|
192
|
+
if (activeRoleId) rightContentLines.push(`\u25B8 ${activeRoleId}`);
|
|
193
|
+
const maxEv = Math.max(5, contentHeight - 3);
|
|
194
|
+
const filtered = activeRoleId ? events.filter(e => e.roleId === activeRoleId) : events;
|
|
195
|
+
const visible = filtered.slice(-maxEv);
|
|
196
|
+
for (const ev of visible) {
|
|
197
|
+
const line = eventLine(ev);
|
|
198
|
+
if (!line) continue;
|
|
199
|
+
// Split multi-line text events into separate lines (preserves markdown)
|
|
200
|
+
const sublines = line.split('\n');
|
|
201
|
+
for (const sl of sublines) {
|
|
202
|
+
if (rightContentLines.length >= maxEv) break;
|
|
203
|
+
rightContentLines.push(sl.slice(0, rightWidth));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (rightContentLines.length === 0) {
|
|
207
|
+
if (activeRoleId && events.length > 0) {
|
|
208
|
+
rightContentLines.push(`No events for ${activeRoleId} (${events.length} total)`);
|
|
209
|
+
rightContentLines.push('Press Enter to show all roles');
|
|
210
|
+
} else {
|
|
211
|
+
rightContentLines.push(waveId ? `Waiting for events... (${events.length} in buffer)` : 'No active stream. Type a directive to start.');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} else if (rightTab === 'info') {
|
|
215
|
+
rightContentLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
|
|
216
|
+
rightContentLines.push(`Directive: ${focusedWave?.directive?.slice(0, rightWidth - 12) || '(idle)'}`);
|
|
217
|
+
rightContentLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
|
|
218
|
+
rightContentLines.push(`Stream: ${streamStatus}`);
|
|
219
|
+
} else if (rightTab === 'docs') {
|
|
220
|
+
// Docs: scan .md files with j/k scroll + Enter to open
|
|
344
221
|
try {
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
222
|
+
const skip = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude', '.obsidian']);
|
|
223
|
+
const mdFiles: string[] = [];
|
|
224
|
+
const mdPaths: string[] = []; // full paths for vim
|
|
225
|
+
const walk = (dir: string, depth: number) => {
|
|
226
|
+
if (depth > 3 || mdFiles.length > 200) return;
|
|
227
|
+
try {
|
|
228
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
229
|
+
if (skip.has(e.name)) continue;
|
|
230
|
+
const full = path.join(dir, e.name);
|
|
231
|
+
if (e.isDirectory()) walk(full, depth + 1);
|
|
232
|
+
else if (e.name.endsWith('.md')) {
|
|
233
|
+
mdFiles.push(full.replace(companyRoot + '/', ''));
|
|
234
|
+
mdPaths.push(full);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch {}
|
|
238
|
+
};
|
|
239
|
+
walk(companyRoot, 0);
|
|
240
|
+
mdFiles.sort();
|
|
241
|
+
mdPaths.sort();
|
|
242
|
+
// Cap docsIndex
|
|
243
|
+
const cappedIdx = Math.min(docsIndex, mdFiles.length - 1);
|
|
244
|
+
if (cappedIdx !== docsIndex) setDocsIndex(Math.max(0, cappedIdx));
|
|
245
|
+
selectedDocPath = mdPaths[cappedIdx] ?? null;
|
|
246
|
+
|
|
247
|
+
const maxVisible = Math.max(5, termHeight - 12);
|
|
248
|
+
const scrollStart = Math.max(0, Math.min(cappedIdx - 3, mdFiles.length - maxVisible));
|
|
249
|
+
rightContentLines.push(`${mdFiles.length} documents [j/k] browse [Enter] ${process.env.EDITOR || 'vim'}`);
|
|
250
|
+
for (let i = scrollStart; i < Math.min(scrollStart + maxVisible, mdFiles.length); i++) {
|
|
251
|
+
const selected = i === cappedIdx;
|
|
252
|
+
const prefix = selected ? '\u25B6 ' : ' ';
|
|
253
|
+
rightContentLines.push(`${prefix}${mdFiles[i].slice(0, rightWidth - 4)}`);
|
|
349
254
|
}
|
|
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">
|
|
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
|
-
)}
|
|
255
|
+
} catch {
|
|
256
|
+
rightContentLines.push('Cannot scan documents');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
455
259
|
|
|
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>
|
|
260
|
+
// === Merge left + right, cap to terminal height ===
|
|
261
|
+
const maxRows = contentHeight;
|
|
262
|
+
|
|
263
|
+
const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
|
|
264
|
+
for (let i = 0; i < maxRows; i++) {
|
|
265
|
+
const ll = leftLines[i];
|
|
266
|
+
rows.push({
|
|
267
|
+
left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
|
|
268
|
+
right: rightContentLines[i] ?? '',
|
|
269
|
+
leftSelected: ll?.selected ?? false,
|
|
270
|
+
leftWorking: ll?.working ?? false,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
474
273
|
|
|
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
|
-
)}
|
|
274
|
+
// Tab bar
|
|
275
|
+
const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
|
|
276
|
+
t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
|
|
277
|
+
).join(' ');
|
|
507
278
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
</Box>
|
|
513
|
-
</Box>
|
|
514
|
-
)}
|
|
515
|
-
</Box>
|
|
516
|
-
)}
|
|
279
|
+
// Wave tabs
|
|
280
|
+
const waveTabs = waves.length > 1
|
|
281
|
+
? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
|
|
282
|
+
: '';
|
|
517
283
|
|
|
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>
|
|
284
|
+
const sep = '\u2500'.repeat(Math.min(termCols, 160));
|
|
285
|
+
const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
|
|
529
286
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
</Box>
|
|
287
|
+
return (
|
|
288
|
+
<Box flexDirection="column">
|
|
289
|
+
{/* Header */}
|
|
290
|
+
<Text>
|
|
291
|
+
<Text color="green" bold>{'W' + focusedWaveIndex}</Text>
|
|
292
|
+
<Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
|
|
293
|
+
<Text color="gray">{' \u2502 '}</Text>
|
|
294
|
+
<Text color="cyan" bold>{tabBar}</Text>
|
|
295
|
+
<Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
|
|
296
|
+
<Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
|
|
297
|
+
</Text>
|
|
298
|
+
<Text color="gray">{sep}</Text>
|
|
299
|
+
|
|
300
|
+
{/* Content rows: left (OrgTree) │ right (Stream/Info) */}
|
|
301
|
+
{rows.map((row, i) => (
|
|
302
|
+
<Text key={i}>
|
|
303
|
+
<Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
|
|
304
|
+
<Text color="gray">{' \u2502 '}</Text>
|
|
305
|
+
<Text color="white">{row.right}</Text>
|
|
306
|
+
</Text>
|
|
307
|
+
))}
|
|
552
308
|
|
|
553
309
|
{/* Footer */}
|
|
554
|
-
<
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
<Text color="gray" dimColor>
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
</Text>
|
|
562
|
-
</Box>
|
|
310
|
+
<Text color="gray">{sep}</Text>
|
|
311
|
+
<Text>
|
|
312
|
+
{waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
|
|
313
|
+
<Text color="gray" dimColor>{rightTab === 'docs' ? '[h/l] tab [j/k] browse [Enter] open ' : '[h/l] tab [j/k] role [Enter] filter '}</Text>
|
|
314
|
+
{waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
|
|
315
|
+
<Text color="gray" dimColor>{'[Esc] back'}</Text>
|
|
316
|
+
</Text>
|
|
563
317
|
</Box>
|
|
564
318
|
);
|
|
565
319
|
};
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StreamView —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* StreamView — stream panel for Panel Mode
|
|
3
|
+
* Simplified to single Text render to prevent yoga OOM on wide terminals.
|
|
4
|
+
* Previous: 30 events × 3 React elements = 90 yoga nodes → OOM on 245+ columns
|
|
5
|
+
* Now: 1 Text element with pre-formatted string
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import React from 'react';
|
|
8
9
|
import { Box, Text } from 'ink';
|
|
9
10
|
import type { SSEEvent } from '../api';
|
|
10
|
-
import { getRoleColor } from '../theme';
|
|
11
11
|
|
|
12
12
|
interface StreamViewProps {
|
|
13
13
|
events: SSEEvent[];
|
|
@@ -21,111 +21,58 @@ function formatTime(ts: string): string {
|
|
|
21
21
|
try {
|
|
22
22
|
const d = new Date(ts);
|
|
23
23
|
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
24
|
-
} catch {
|
|
25
|
-
return '--:--:--';
|
|
26
|
-
}
|
|
24
|
+
} catch { return '--:--:--'; }
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
content: `\u25B6 Started: ${(event.data.task as string)?.replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80) ?? ''}`,
|
|
34
|
-
contentColor: 'green',
|
|
35
|
-
};
|
|
27
|
+
function eventToLine(event: SSEEvent): string | null {
|
|
28
|
+
const time = formatTime(event.ts);
|
|
29
|
+
const role = event.roleId.padEnd(12);
|
|
36
30
|
|
|
31
|
+
switch (event.type) {
|
|
32
|
+
case 'msg:start': {
|
|
33
|
+
const task = ((event.data.task as string) ?? '').replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80);
|
|
34
|
+
return `${time} ${role} \u25B6 Started: ${task}`;
|
|
35
|
+
}
|
|
37
36
|
case 'msg:done': {
|
|
38
37
|
const turns = event.data.turns as number | undefined;
|
|
39
|
-
return {
|
|
40
|
-
content: `\u2713 Done${turns ? ` (${turns} turns)` : ''}`,
|
|
41
|
-
contentColor: 'green',
|
|
42
|
-
};
|
|
38
|
+
return `${time} ${role} \u2713 Done${turns ? ` (${turns} turns)` : ''}`;
|
|
43
39
|
}
|
|
44
|
-
|
|
45
40
|
case 'msg:error':
|
|
46
|
-
return {
|
|
47
|
-
content: `\u2717 Error: ${(event.data.error as string ?? event.data.message as string ?? '').slice(0, 120)}`,
|
|
48
|
-
contentColor: 'red',
|
|
49
|
-
};
|
|
50
|
-
|
|
41
|
+
return `${time} ${role} \u2717 ${((event.data.error ?? event.data.message) as string ?? '').slice(0, 80)}`;
|
|
51
42
|
case 'text': {
|
|
52
|
-
const text = ((event.data.text as string) ?? '');
|
|
53
|
-
if (!text
|
|
54
|
-
|
|
55
|
-
return { content: text, contentColor: 'white' };
|
|
43
|
+
const text = ((event.data.text as string) ?? '').trim();
|
|
44
|
+
if (!text) return null;
|
|
45
|
+
return `${time} ${role} ${text.slice(0, 120)}`;
|
|
56
46
|
}
|
|
57
|
-
|
|
58
47
|
case 'thinking': {
|
|
59
|
-
const text = ((event.data.text as string) ?? '').slice(0,
|
|
60
|
-
if (!text
|
|
61
|
-
return {
|
|
48
|
+
const text = ((event.data.text as string) ?? '').trim().slice(0, 100);
|
|
49
|
+
if (!text) return null;
|
|
50
|
+
return `${time} ${role} \uD83D\uDCAD ${text}`;
|
|
62
51
|
}
|
|
63
|
-
|
|
64
52
|
case 'tool:start': {
|
|
65
53
|
const name = (event.data.name as string) ?? 'tool';
|
|
66
|
-
const input = event.data.input;
|
|
54
|
+
const input = event.data.input as Record<string, unknown> | undefined;
|
|
67
55
|
let detail = '';
|
|
68
|
-
if (input
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
else if (
|
|
72
|
-
else if (inp.pattern) detail = ` ${String(inp.pattern)}`;
|
|
73
|
-
else detail = ` ${JSON.stringify(input).slice(0, 80)}`;
|
|
56
|
+
if (input) {
|
|
57
|
+
if (input.file_path) detail = ` ${String(input.file_path).slice(0, 60)}`;
|
|
58
|
+
else if (input.command) detail = ` ${String(input.command).slice(0, 60)}`;
|
|
59
|
+
else if (input.pattern) detail = ` ${String(input.pattern)}`;
|
|
74
60
|
}
|
|
75
|
-
return {
|
|
76
|
-
content: `\u2192 ${name}${detail}`,
|
|
77
|
-
contentColor: 'gray',
|
|
78
|
-
};
|
|
61
|
+
return `${time} ${role} \u2192 ${name}${detail}`;
|
|
79
62
|
}
|
|
80
|
-
|
|
81
|
-
case 'tool:result':
|
|
82
|
-
return {
|
|
83
|
-
content: `\u2190 ${(event.data.name as string) ?? 'tool'} done`,
|
|
84
|
-
contentColor: 'gray',
|
|
85
|
-
};
|
|
86
|
-
|
|
87
63
|
case 'dispatch:start':
|
|
88
|
-
return {
|
|
89
|
-
content: `\u21D2 dispatch ${event.data.targetRole as string ?? ''}: ${(event.data.task as string)?.replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80) ?? ''}`,
|
|
90
|
-
contentColor: 'yellow',
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
case 'dispatch:done':
|
|
94
|
-
return {
|
|
95
|
-
content: `\u21D0 ${event.data.targetRole as string ?? ''} completed`,
|
|
96
|
-
contentColor: 'yellow',
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
case 'msg:awaiting_input': {
|
|
100
|
-
const question = (event.data.question as string) ?? '';
|
|
101
|
-
return {
|
|
102
|
-
content: question ? `? ${question.slice(0, 120)}` : '? Awaiting input...',
|
|
103
|
-
contentColor: 'yellow',
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Hidden (truly internal only)
|
|
108
|
-
case 'heartbeat:tick':
|
|
109
|
-
case 'heartbeat:skip':
|
|
110
|
-
case 'prompt:assembled':
|
|
111
|
-
case 'trace:response':
|
|
112
|
-
return null;
|
|
113
|
-
|
|
64
|
+
return `${time} ${role} \u21D2 dispatch ${event.data.targetRole as string ?? ''}`;
|
|
114
65
|
default:
|
|
115
66
|
return null;
|
|
116
67
|
}
|
|
117
68
|
}
|
|
118
69
|
|
|
119
70
|
const StreamViewInner: React.FC<StreamViewProps> = ({
|
|
120
|
-
events,
|
|
121
|
-
allRoleIds,
|
|
122
|
-
streamStatus,
|
|
123
|
-
waveId,
|
|
124
|
-
roleLabel,
|
|
71
|
+
events, allRoleIds, streamStatus, waveId, roleLabel,
|
|
125
72
|
}) => {
|
|
126
|
-
const
|
|
73
|
+
const termRows = process.stdout.rows || 40;
|
|
74
|
+
const maxVisible = Math.min(Math.max(5, termRows - 15), 20);
|
|
127
75
|
const visibleEvents = events.slice(-maxVisible);
|
|
128
|
-
|
|
129
76
|
const turnCount = events.filter(e => e.type === 'text' || e.type === 'tool:start').length;
|
|
130
77
|
|
|
131
78
|
const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming'
|
|
@@ -133,39 +80,24 @@ const StreamViewInner: React.FC<StreamViewProps> = ({
|
|
|
133
80
|
: streamStatus === 'error' ? '\u2717 error'
|
|
134
81
|
: 'idle';
|
|
135
82
|
|
|
83
|
+
// Build single text block (1 yoga node instead of 90+)
|
|
84
|
+
const lines = visibleEvents
|
|
85
|
+
.map(e => eventToLine(e))
|
|
86
|
+
.filter(Boolean) as string[];
|
|
87
|
+
|
|
88
|
+
const content = lines.length > 0
|
|
89
|
+
? lines.join('\n')
|
|
90
|
+
: (waveId ? `Streaming... waiting for ${roleLabel !== 'All' ? roleLabel + ' ' : ''}events` : 'No active stream. Dispatch a wave to start.');
|
|
91
|
+
|
|
136
92
|
return (
|
|
137
|
-
<Box flexDirection="column" paddingX={1}
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
Stream ({roleLabel})
|
|
141
|
-
</Text>
|
|
93
|
+
<Box flexDirection="column" paddingX={1}>
|
|
94
|
+
<Text bold color="cyan">
|
|
95
|
+
Stream ({roleLabel}){' '}
|
|
142
96
|
<Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>
|
|
143
97
|
{statusLabel} {turnCount > 0 ? `turn ${turnCount}` : ''}
|
|
144
98
|
</Text>
|
|
145
|
-
</
|
|
146
|
-
|
|
147
|
-
{visibleEvents.length === 0 && (
|
|
148
|
-
<Box marginTop={1}>
|
|
149
|
-
<Text color="gray" dimColor>
|
|
150
|
-
{waveId
|
|
151
|
-
? `Streaming... waiting for ${roleLabel !== 'All' ? roleLabel + ' ' : ''}events`
|
|
152
|
-
: 'No active stream. Dispatch a wave to start.'}
|
|
153
|
-
</Text>
|
|
154
|
-
</Box>
|
|
155
|
-
)}
|
|
156
|
-
|
|
157
|
-
{visibleEvents.map((event, i) => {
|
|
158
|
-
const rendered = renderEvent(event);
|
|
159
|
-
if (!rendered) return null;
|
|
160
|
-
const roleColor = getRoleColor(event.roleId, allRoleIds);
|
|
161
|
-
return (
|
|
162
|
-
<Box key={`${event.seq}-${i}`}>
|
|
163
|
-
<Text color="gray" dimColor>{formatTime(event.ts)} </Text>
|
|
164
|
-
<Text color={roleColor} bold>{event.roleId.padEnd(12)}</Text>
|
|
165
|
-
<Text color={rendered.contentColor} wrap="truncate">{rendered.content}</Text>
|
|
166
|
-
</Box>
|
|
167
|
-
);
|
|
168
|
-
})}
|
|
99
|
+
</Text>
|
|
100
|
+
<Text color="white" wrap="truncate">{content}</Text>
|
|
169
101
|
</Box>
|
|
170
102
|
);
|
|
171
103
|
};
|