tycono 0.1.96-beta.36 → 0.1.96-beta.38
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/components/CommandMode.tsx +41 -0
- package/src/tui/components/PanelMode.tsx +252 -106
- package/src/tui/utils/markdown.tsx +102 -0
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { Box, Text, Static } from 'ink';
|
|
|
10
10
|
import TextInput from 'ink-text-input';
|
|
11
11
|
import type { SSEEvent } from '../api';
|
|
12
12
|
import { getRoleColor } from '../theme';
|
|
13
|
+
import { renderMarkdownLine } from '../utils/markdown';
|
|
13
14
|
|
|
14
15
|
const SUPERVISOR_ROLE = 'ceo';
|
|
15
16
|
|
|
@@ -20,6 +21,7 @@ export interface StreamLine {
|
|
|
20
21
|
prefix?: string;
|
|
21
22
|
prefixColor?: string;
|
|
22
23
|
indent?: boolean;
|
|
24
|
+
markdown?: boolean; // render text as markdown
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
interface CommandModeProps {
|
|
@@ -56,6 +58,7 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
|
|
|
56
58
|
id: ++lineCounter,
|
|
57
59
|
text,
|
|
58
60
|
color: 'white',
|
|
61
|
+
markdown: true,
|
|
59
62
|
};
|
|
60
63
|
} else {
|
|
61
64
|
return {
|
|
@@ -65,6 +68,7 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
|
|
|
65
68
|
text,
|
|
66
69
|
color: 'white',
|
|
67
70
|
indent: true,
|
|
71
|
+
markdown: true,
|
|
68
72
|
};
|
|
69
73
|
}
|
|
70
74
|
}
|
|
@@ -232,6 +236,43 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
|
|
|
232
236
|
|
|
233
237
|
/** Render a single StreamLine */
|
|
234
238
|
function StreamLineRow({ line }: { line: StreamLine }) {
|
|
239
|
+
// Multi-line markdown: split and render each line
|
|
240
|
+
if (line.markdown && line.text.includes('\n')) {
|
|
241
|
+
const lines = line.text.split('\n');
|
|
242
|
+
return (
|
|
243
|
+
<Box flexDirection="column">
|
|
244
|
+
{lines.map((l, i) => (
|
|
245
|
+
<Box key={i}>
|
|
246
|
+
{line.indent && <Text> </Text>}
|
|
247
|
+
{line.prefix && i === 0 && (
|
|
248
|
+
<Text color={line.prefixColor} bold>
|
|
249
|
+
{(line.prefix).padEnd(12)}
|
|
250
|
+
</Text>
|
|
251
|
+
)}
|
|
252
|
+
{line.prefix && i > 0 && <Text>{' '.repeat(12)}</Text>}
|
|
253
|
+
{renderMarkdownLine(l, `${line.id}-${i}`)}
|
|
254
|
+
</Box>
|
|
255
|
+
))}
|
|
256
|
+
</Box>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Single line with markdown
|
|
261
|
+
if (line.markdown) {
|
|
262
|
+
return (
|
|
263
|
+
<Box>
|
|
264
|
+
{line.indent && <Text> </Text>}
|
|
265
|
+
{line.prefix && (
|
|
266
|
+
<Text color={line.prefixColor} bold>
|
|
267
|
+
{(line.prefix).padEnd(12)}
|
|
268
|
+
</Text>
|
|
269
|
+
)}
|
|
270
|
+
{renderMarkdownLine(line.text, line.id)}
|
|
271
|
+
</Box>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Plain text (tools, system messages)
|
|
235
276
|
return (
|
|
236
277
|
<Box>
|
|
237
278
|
{line.indent && <Text> </Text>}
|
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PanelMode — Wave-scoped team view
|
|
2
|
+
* PanelMode — Wave-scoped team view with right-panel tabs
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Right: Selected role's resources + stream
|
|
4
|
+
* Left: Wave title + Org Tree (wave-scoped) + Wave tabs
|
|
5
|
+
* Right: [Stream] [Docs] [Info] — tab switching with h/l
|
|
7
6
|
*
|
|
8
7
|
* Navigation:
|
|
9
|
-
* j/k — move in Org Tree (auto-selects)
|
|
8
|
+
* j/k — move in Org Tree (auto-selects) or scroll in Docs
|
|
9
|
+
* h/l — switch right panel tab
|
|
10
10
|
* 1-9 — switch wave focus
|
|
11
|
-
* Enter — toggle filtered/all
|
|
11
|
+
* Enter — Stream: toggle filtered/all | Docs: open in vim
|
|
12
12
|
* Esc — return to Command Mode
|
|
13
|
-
* Ctrl+C — quit
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
17
16
|
import { Box, Text, useInput } from 'ink';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
18
19
|
import { OrgTree } from './OrgTree';
|
|
19
20
|
import { StreamView } from './StreamView';
|
|
20
21
|
import type { OrgNode } from '../store';
|
|
21
22
|
import type { SSEEvent, ActiveSessionInfo, SessionInfo } from '../api';
|
|
22
23
|
import type { WaveInfo } from '../hooks/useCommand';
|
|
23
24
|
|
|
25
|
+
type RightTab = 'stream' | 'docs' | 'info';
|
|
26
|
+
|
|
24
27
|
interface PanelModeProps {
|
|
25
28
|
tree: OrgNode[];
|
|
26
29
|
flatRoles: string[];
|
|
@@ -39,7 +42,6 @@ interface PanelModeProps {
|
|
|
39
42
|
onFocusWave: (waveId: string) => void;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
/** Get wave-scoped role statuses */
|
|
43
45
|
function getWaveScopedStatuses(
|
|
44
46
|
allSessions: SessionInfo[],
|
|
45
47
|
focusedWaveId: string | null,
|
|
@@ -48,30 +50,22 @@ function getWaveScopedStatuses(
|
|
|
48
50
|
const statuses: Record<string, string> = {};
|
|
49
51
|
for (const s of allSessions) {
|
|
50
52
|
if (s.waveId !== focusedWaveId) continue;
|
|
51
|
-
if (s.status === 'active')
|
|
52
|
-
|
|
53
|
-
} else if (!statuses[s.roleId]) {
|
|
54
|
-
statuses[s.roleId] = 'done';
|
|
55
|
-
}
|
|
53
|
+
if (s.status === 'active') statuses[s.roleId] = 'working';
|
|
54
|
+
else if (!statuses[s.roleId]) statuses[s.roleId] = 'done';
|
|
56
55
|
}
|
|
57
56
|
return statuses;
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
/** Find active session for a role in focused wave */
|
|
61
59
|
function findSessionForRole(
|
|
62
60
|
activeSessions: ActiveSessionInfo[],
|
|
63
61
|
allSessions: SessionInfo[],
|
|
64
62
|
roleId: string,
|
|
65
63
|
focusedWaveId: string | null,
|
|
66
64
|
): ActiveSessionInfo | null {
|
|
67
|
-
// First try: session with matching waveId
|
|
68
65
|
if (focusedWaveId) {
|
|
69
66
|
const waveSes = allSessions.find(s => s.waveId === focusedWaveId && s.roleId === roleId && s.status === 'active');
|
|
70
|
-
if (waveSes)
|
|
71
|
-
return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
|
|
72
|
-
}
|
|
67
|
+
if (waveSes) return activeSessions.find(s => s.sessionId === waveSes.id) ?? null;
|
|
73
68
|
}
|
|
74
|
-
// Fallback: any active session for this role
|
|
75
69
|
return activeSessions.find(s => s.roleId === roleId && s.status === 'active') ?? null;
|
|
76
70
|
}
|
|
77
71
|
|
|
@@ -82,24 +76,41 @@ function elapsed(startedAt: string): string {
|
|
|
82
76
|
return `${Math.floor(ms / 3600_000)}h`;
|
|
83
77
|
}
|
|
84
78
|
|
|
79
|
+
/** Extract files created/modified in this wave from SSE events */
|
|
80
|
+
function extractWaveFiles(events: SSEEvent[]): string[] {
|
|
81
|
+
const files = new Set<string>();
|
|
82
|
+
for (const e of events) {
|
|
83
|
+
if (e.type === 'tool:start') {
|
|
84
|
+
const name = (e.data.name as string) ?? '';
|
|
85
|
+
const input = e.data.input as Record<string, unknown> | undefined;
|
|
86
|
+
if (['Write', 'Edit', 'NotebookEdit'].includes(name) && input?.file_path) {
|
|
87
|
+
files.add(String(input.file_path));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return Array.from(files);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Read file preview (first N lines) */
|
|
95
|
+
function readFilePreview(filePath: string, maxLines: number): string[] {
|
|
96
|
+
try {
|
|
97
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
98
|
+
return content.split('\n').slice(0, maxLines);
|
|
99
|
+
} catch {
|
|
100
|
+
return ['(cannot read file)'];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
export const PanelMode: React.FC<PanelModeProps> = ({
|
|
86
|
-
tree,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
selectedRoleIndex,
|
|
90
|
-
selectedRoleId,
|
|
91
|
-
streamStatus,
|
|
92
|
-
waveId,
|
|
93
|
-
activeSessions,
|
|
94
|
-
allSessions,
|
|
95
|
-
waves,
|
|
96
|
-
focusedWaveId,
|
|
97
|
-
onMove,
|
|
98
|
-
onSelect,
|
|
99
|
-
onEscape,
|
|
100
|
-
onFocusWave,
|
|
105
|
+
tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
|
|
106
|
+
streamStatus, waveId, activeSessions, allSessions, waves,
|
|
107
|
+
focusedWaveId, onMove, onSelect, onEscape, onFocusWave,
|
|
101
108
|
}) => {
|
|
102
109
|
const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
|
|
110
|
+
const [rightTab, setRightTab] = useState<RightTab>('stream');
|
|
111
|
+
const [docsIndex, setDocsIndex] = useState(0);
|
|
112
|
+
const [docsScroll, setDocsScroll] = useState(0);
|
|
113
|
+
|
|
103
114
|
useEffect(() => {
|
|
104
115
|
const onResize = () => setTermHeight(process.stdout.rows || 30);
|
|
105
116
|
process.stdout.on('resize', onResize);
|
|
@@ -108,13 +119,11 @@ export const PanelMode: React.FC<PanelModeProps> = ({
|
|
|
108
119
|
|
|
109
120
|
const separatorStr = useMemo(() => '\u2502\n'.repeat(Math.max(5, termHeight - 8)), [termHeight]);
|
|
110
121
|
|
|
111
|
-
// Wave-scoped statuses for Org Tree
|
|
112
122
|
const waveScopedStatuses = useMemo(
|
|
113
123
|
() => getWaveScopedStatuses(allSessions, focusedWaveId),
|
|
114
124
|
[allSessions, focusedWaveId],
|
|
115
125
|
);
|
|
116
126
|
|
|
117
|
-
// Override tree node statuses with wave-scoped values
|
|
118
127
|
const waveScopedTree = useMemo(() => {
|
|
119
128
|
function scopeNode(node: OrgNode): OrgNode {
|
|
120
129
|
return {
|
|
@@ -126,11 +135,74 @@ export const PanelMode: React.FC<PanelModeProps> = ({
|
|
|
126
135
|
return tree.map(scopeNode);
|
|
127
136
|
}, [tree, waveScopedStatuses]);
|
|
128
137
|
|
|
138
|
+
// Files created in this wave
|
|
139
|
+
const waveFiles = useMemo(() => extractWaveFiles(events), [events]);
|
|
140
|
+
|
|
141
|
+
// File preview for selected doc
|
|
142
|
+
const selectedFile = waveFiles[docsIndex] ?? null;
|
|
143
|
+
const filePreview = useMemo(() => {
|
|
144
|
+
if (!selectedFile || rightTab !== 'docs') return [];
|
|
145
|
+
return readFilePreview(selectedFile, 100);
|
|
146
|
+
}, [selectedFile, rightTab]);
|
|
147
|
+
|
|
129
148
|
useInput((input, key) => {
|
|
130
149
|
if (key.escape) { onEscape(); return; }
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (key.
|
|
150
|
+
|
|
151
|
+
// h/l: switch right panel tab
|
|
152
|
+
if (input === 'h' || (key.leftArrow && rightTab !== 'stream')) {
|
|
153
|
+
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
154
|
+
const idx = tabs.indexOf(rightTab);
|
|
155
|
+
if (idx > 0) { setRightTab(tabs[idx - 1]); setDocsScroll(0); }
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (input === 'l' || (key.rightArrow && rightTab !== 'info')) {
|
|
159
|
+
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
160
|
+
const idx = tabs.indexOf(rightTab);
|
|
161
|
+
if (idx < tabs.length - 1) { setRightTab(tabs[idx + 1]); setDocsScroll(0); }
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// j/k: context-dependent
|
|
166
|
+
if (key.upArrow || input === 'k') {
|
|
167
|
+
if (rightTab === 'docs' && docsScroll > 0) {
|
|
168
|
+
setDocsScroll(s => Math.max(0, s - 3));
|
|
169
|
+
} else if (rightTab === 'stream') {
|
|
170
|
+
onMove('up');
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (key.downArrow || input === 'j') {
|
|
175
|
+
if (rightTab === 'docs') {
|
|
176
|
+
setDocsScroll(s => s + 3);
|
|
177
|
+
} else if (rightTab === 'stream') {
|
|
178
|
+
onMove('down');
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Tab key for cycling docs files
|
|
184
|
+
if (key.tab && rightTab === 'docs') {
|
|
185
|
+
setDocsIndex(i => (i + 1) % Math.max(1, waveFiles.length));
|
|
186
|
+
setDocsScroll(0);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Enter
|
|
191
|
+
if (key.return) {
|
|
192
|
+
if (rightTab === 'docs' && selectedFile) {
|
|
193
|
+
// Open in vim
|
|
194
|
+
const editor = process.env.EDITOR || 'vim';
|
|
195
|
+
try {
|
|
196
|
+
execSync(`${editor} "${selectedFile}"`, { stdio: 'inherit' });
|
|
197
|
+
} catch { /* user quit editor */ }
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (rightTab === 'stream') {
|
|
201
|
+
onSelect();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
134
206
|
// 1-9: wave switch
|
|
135
207
|
const num = parseInt(input, 10);
|
|
136
208
|
if (num >= 1 && num <= 9 && num <= waves.length) {
|
|
@@ -147,18 +219,15 @@ export const PanelMode: React.FC<PanelModeProps> = ({
|
|
|
147
219
|
? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
|
|
148
220
|
: 'All';
|
|
149
221
|
|
|
150
|
-
// Find resource info for selected role (wave-scoped)
|
|
151
222
|
const selectedSession = selectedRoleId
|
|
152
223
|
? findSessionForRole(activeSessions, allSessions, selectedRoleId, focusedWaveId)
|
|
153
224
|
: null;
|
|
154
225
|
|
|
155
|
-
// Focused wave info
|
|
156
226
|
const focusedWave = waves.find(w => w.waveId === focusedWaveId);
|
|
157
227
|
const focusedWaveIndex = focusedWaveId
|
|
158
228
|
? waves.findIndex(w => w.waveId === focusedWaveId) + 1
|
|
159
229
|
: 0;
|
|
160
230
|
|
|
161
|
-
// Wave session count for display
|
|
162
231
|
const waveSessionCount = focusedWaveId
|
|
163
232
|
? allSessions.filter(s => s.waveId === focusedWaveId).length
|
|
164
233
|
: 0;
|
|
@@ -167,42 +236,41 @@ export const PanelMode: React.FC<PanelModeProps> = ({
|
|
|
167
236
|
|
|
168
237
|
return (
|
|
169
238
|
<Box flexDirection="column" flexGrow={1}>
|
|
170
|
-
{/* Main content */}
|
|
171
239
|
<Box flexGrow={1}>
|
|
172
240
|
{/* Left: Wave title + Org Tree + Wave tabs */}
|
|
173
241
|
<Box flexDirection="column" width={leftWidth}>
|
|
174
|
-
{
|
|
175
|
-
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
{
|
|
242
|
+
<Box paddingX={1}>
|
|
243
|
+
<Text color="green" bold>W{focusedWaveIndex}</Text>
|
|
244
|
+
<Text color="gray"> </Text>
|
|
245
|
+
<Text color="white" wrap="truncate">
|
|
246
|
+
{focusedWave?.directive ? focusedWave.directive.slice(0, leftWidth - 6) : '(idle)'}
|
|
247
|
+
</Text>
|
|
248
|
+
</Box>
|
|
249
|
+
{waveSessionCount > 0 && (
|
|
250
|
+
<Box paddingX={1}>
|
|
251
|
+
<Text color="gray">{waveSessionCount} sessions</Text>
|
|
179
252
|
</Box>
|
|
180
253
|
)}
|
|
181
254
|
|
|
182
|
-
{/* Org Tree (wave-scoped statuses) */}
|
|
183
255
|
<OrgTree
|
|
184
256
|
tree={waveScopedTree}
|
|
185
|
-
focused={
|
|
257
|
+
focused={rightTab === 'stream'}
|
|
186
258
|
selectedIndex={selectedRoleIndex}
|
|
187
259
|
flatRoles={flatRoles}
|
|
188
260
|
ceoStatus={waveScopedStatuses['ceo'] ?? 'idle'}
|
|
189
261
|
/>
|
|
190
262
|
|
|
191
|
-
{/* Wave tabs at bottom — compact inline */}
|
|
192
263
|
{waves.length > 1 && (
|
|
193
264
|
<Box paddingX={1} marginTop={1}>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
</Text>
|
|
204
|
-
);
|
|
205
|
-
})}
|
|
265
|
+
{waves.map((w, i) => (
|
|
266
|
+
<Box key={w.waveId} marginRight={1}>
|
|
267
|
+
<Text
|
|
268
|
+
color={w.waveId === focusedWaveId ? 'green' : 'gray'}
|
|
269
|
+
bold={w.waveId === focusedWaveId}
|
|
270
|
+
inverse={w.waveId === focusedWaveId}
|
|
271
|
+
>{` ${i + 1} `}</Text>
|
|
272
|
+
</Box>
|
|
273
|
+
))}
|
|
206
274
|
</Box>
|
|
207
275
|
)}
|
|
208
276
|
</Box>
|
|
@@ -212,71 +280,149 @@ export const PanelMode: React.FC<PanelModeProps> = ({
|
|
|
212
280
|
<Text color="gray">{separatorStr}</Text>
|
|
213
281
|
</Box>
|
|
214
282
|
|
|
215
|
-
{/* Right:
|
|
283
|
+
{/* Right: Tabbed panel */}
|
|
216
284
|
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
|
217
|
-
{/*
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
<Box
|
|
221
|
-
<Text
|
|
222
|
-
|
|
223
|
-
{
|
|
224
|
-
{
|
|
285
|
+
{/* Tab bar */}
|
|
286
|
+
<Box paddingX={1} marginBottom={0}>
|
|
287
|
+
{(['stream', 'docs', 'info'] as RightTab[]).map(tab => (
|
|
288
|
+
<Box key={tab} marginRight={1}>
|
|
289
|
+
<Text
|
|
290
|
+
color={rightTab === tab ? 'cyan' : 'gray'}
|
|
291
|
+
bold={rightTab === tab}
|
|
292
|
+
inverse={rightTab === tab}
|
|
293
|
+
>
|
|
294
|
+
{` ${tab.charAt(0).toUpperCase() + tab.slice(1)} `}
|
|
225
295
|
</Text>
|
|
226
296
|
</Box>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
297
|
+
))}
|
|
298
|
+
<Text color="gray" dimColor> [h/l] switch</Text>
|
|
299
|
+
</Box>
|
|
300
|
+
|
|
301
|
+
{/* Stream tab */}
|
|
302
|
+
{rightTab === 'stream' && (
|
|
303
|
+
<>
|
|
304
|
+
{selectedRoleId && selectedSession && (
|
|
305
|
+
<Box flexDirection="column" paddingX={1}>
|
|
306
|
+
<Box justifyContent="space-between">
|
|
307
|
+
<Text bold color="cyan">{selectedRoleId}</Text>
|
|
308
|
+
<Text color={selectedSession.status === 'active' ? 'green' : 'gray'}>
|
|
309
|
+
{selectedSession.status === 'active' ? '\u25CF' : '\u25CB'} {selectedSession.status}
|
|
310
|
+
{selectedSession.startedAt ? ` (${elapsed(selectedSession.startedAt)})` : ''}
|
|
311
|
+
</Text>
|
|
312
|
+
</Box>
|
|
313
|
+
{selectedSession.ports.api > 0 && (
|
|
314
|
+
<Text color="gray">Port API:{selectedSession.ports.api} Vite:{selectedSession.ports.vite}</Text>
|
|
315
|
+
)}
|
|
316
|
+
<Text color="gray">{'\u2500'.repeat(40)}</Text>
|
|
234
317
|
</Box>
|
|
235
318
|
)}
|
|
236
|
-
{selectedSession
|
|
237
|
-
<Box>
|
|
238
|
-
<Text color="
|
|
239
|
-
<Text color="
|
|
319
|
+
{selectedRoleId && !selectedSession && (
|
|
320
|
+
<Box flexDirection="column" paddingX={1}>
|
|
321
|
+
<Text bold color="cyan">{selectedRoleId}</Text>
|
|
322
|
+
<Text color="gray">(not active in this wave)</Text>
|
|
323
|
+
<Text color="gray">{'\u2500'.repeat(40)}</Text>
|
|
240
324
|
</Box>
|
|
241
325
|
)}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
326
|
+
<StreamView
|
|
327
|
+
events={roleEvents}
|
|
328
|
+
allRoleIds={flatRoles}
|
|
329
|
+
streamStatus={streamStatus}
|
|
330
|
+
waveId={waveId}
|
|
331
|
+
roleLabel={roleLabel}
|
|
332
|
+
/>
|
|
333
|
+
</>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
{/* Docs tab */}
|
|
337
|
+
{rightTab === 'docs' && (
|
|
338
|
+
<Box flexDirection="column" paddingX={1} flexGrow={1}>
|
|
339
|
+
{waveFiles.length === 0 ? (
|
|
340
|
+
<Text color="gray">No files created in this wave yet.</Text>
|
|
341
|
+
) : (
|
|
342
|
+
<>
|
|
343
|
+
{/* File list */}
|
|
344
|
+
<Box marginBottom={1}>
|
|
345
|
+
<Text color="gray">Files ({waveFiles.length}): </Text>
|
|
346
|
+
{waveFiles.map((f, i) => (
|
|
347
|
+
<Box key={f} marginRight={1}>
|
|
348
|
+
<Text
|
|
349
|
+
color={i === docsIndex ? 'cyan' : 'gray'}
|
|
350
|
+
bold={i === docsIndex}
|
|
351
|
+
inverse={i === docsIndex}
|
|
352
|
+
>
|
|
353
|
+
{` ${f.split('/').pop()} `}
|
|
354
|
+
</Text>
|
|
355
|
+
</Box>
|
|
356
|
+
))}
|
|
357
|
+
<Text color="gray" dimColor> [Tab] next</Text>
|
|
358
|
+
</Box>
|
|
359
|
+
|
|
360
|
+
{/* File preview */}
|
|
361
|
+
{selectedFile && (
|
|
362
|
+
<Box flexDirection="column">
|
|
363
|
+
<Text color="cyan" bold>{selectedFile.split('/').slice(-2).join('/')}</Text>
|
|
364
|
+
<Text color="gray">{'\u2500'.repeat(50)}</Text>
|
|
365
|
+
{filePreview.slice(docsScroll, docsScroll + termHeight - 12).map((line, i) => (
|
|
366
|
+
<Text key={i} color="white" wrap="wrap">{line}</Text>
|
|
367
|
+
))}
|
|
368
|
+
{filePreview.length > termHeight - 12 && (
|
|
369
|
+
<Text color="gray" dimColor>
|
|
370
|
+
{docsScroll > 0 ? '\u2191 ' : ''}j/k scroll | {filePreview.length - docsScroll} lines remaining
|
|
371
|
+
</Text>
|
|
372
|
+
)}
|
|
373
|
+
</Box>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
<Box marginTop={1}>
|
|
377
|
+
<Text color="gray" dimColor>[Enter] open in {process.env.EDITOR || 'vim'} | [Tab] next file | [j/k] scroll</Text>
|
|
378
|
+
</Box>
|
|
379
|
+
</>
|
|
247
380
|
)}
|
|
248
|
-
<Text color="gray">{'\u2500'.repeat(40)}</Text>
|
|
249
381
|
</Box>
|
|
250
382
|
)}
|
|
251
383
|
|
|
252
|
-
{
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
<Text color="
|
|
384
|
+
{/* Info tab */}
|
|
385
|
+
{rightTab === 'info' && (
|
|
386
|
+
<Box flexDirection="column" paddingX={1}>
|
|
387
|
+
<Text bold color="cyan">Wave Info</Text>
|
|
256
388
|
<Text color="gray">{'\u2500'.repeat(40)}</Text>
|
|
389
|
+
<Text color="white">Wave: {focusedWave?.waveId ?? 'none'}</Text>
|
|
390
|
+
<Text color="white">Directive: {focusedWave?.directive || '(idle)'}</Text>
|
|
391
|
+
<Text color="white">Sessions: {waveSessionCount}</Text>
|
|
392
|
+
<Text color="white">Files modified: {waveFiles.length}</Text>
|
|
393
|
+
<Text color="white">SSE events: {events.length}</Text>
|
|
394
|
+
|
|
395
|
+
{/* Active sessions in this wave */}
|
|
396
|
+
{waveSessionCount > 0 && (
|
|
397
|
+
<>
|
|
398
|
+
<Text color="gray" bold>{'\n'}Active in this wave:</Text>
|
|
399
|
+
{allSessions
|
|
400
|
+
.filter(s => s.waveId === focusedWaveId && s.status === 'active')
|
|
401
|
+
.slice(0, 10)
|
|
402
|
+
.map(s => {
|
|
403
|
+
const port = activeSessions.find(a => a.sessionId === s.id);
|
|
404
|
+
return (
|
|
405
|
+
<Text key={s.id} color="white">
|
|
406
|
+
{` ${s.roleId.padEnd(12)} ${s.id.slice(0, 20)} ${port ? `API:${port.ports.api}` : ''}`}
|
|
407
|
+
</Text>
|
|
408
|
+
);
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
257
413
|
</Box>
|
|
258
414
|
)}
|
|
259
|
-
|
|
260
|
-
{/* Stream */}
|
|
261
|
-
<StreamView
|
|
262
|
-
events={roleEvents}
|
|
263
|
-
allRoleIds={flatRoles}
|
|
264
|
-
streamStatus={streamStatus}
|
|
265
|
-
waveId={waveId}
|
|
266
|
-
roleLabel={roleLabel}
|
|
267
|
-
/>
|
|
268
415
|
</Box>
|
|
269
416
|
</Box>
|
|
270
417
|
|
|
271
|
-
{/*
|
|
418
|
+
{/* Footer */}
|
|
272
419
|
<Box width="100%">
|
|
273
420
|
<Text color="gray">{'\u2500'.repeat(process.stdout.columns || 70)}</Text>
|
|
274
421
|
</Box>
|
|
275
|
-
|
|
276
|
-
{/* Footer hints */}
|
|
277
422
|
<Box paddingX={1} justifyContent="center">
|
|
278
423
|
<Text color="gray" dimColor>
|
|
279
|
-
[
|
|
424
|
+
[h/l] tab [j/k] {rightTab === 'stream' ? 'role' : 'scroll'} {rightTab === 'docs' ? '[Enter] vim ' : ''}
|
|
425
|
+
{waves.length > 1 ? '[1-9] wave ' : ''}[Esc] command
|
|
280
426
|
</Text>
|
|
281
427
|
</Box>
|
|
282
428
|
</Box>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Markdown Renderer
|
|
3
|
+
*
|
|
4
|
+
* Converts markdown text to Ink <Text> elements with basic formatting:
|
|
5
|
+
* - **bold** → bold text
|
|
6
|
+
* - `code` → dimmed text
|
|
7
|
+
* - ## heading → bold colored text
|
|
8
|
+
* - --- → horizontal line
|
|
9
|
+
* - | table | → kept as-is (monospace already works)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { Text } from 'ink';
|
|
14
|
+
|
|
15
|
+
interface Segment {
|
|
16
|
+
text: string;
|
|
17
|
+
bold?: boolean;
|
|
18
|
+
dim?: boolean;
|
|
19
|
+
color?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parse inline markdown (bold, code) into segments */
|
|
23
|
+
function parseInline(text: string): Segment[] {
|
|
24
|
+
const segments: Segment[] = [];
|
|
25
|
+
let remaining = text;
|
|
26
|
+
|
|
27
|
+
while (remaining.length > 0) {
|
|
28
|
+
// Bold: **text**
|
|
29
|
+
const boldMatch = remaining.match(/^(.*?)\*\*(.+?)\*\*(.*)/s);
|
|
30
|
+
if (boldMatch) {
|
|
31
|
+
if (boldMatch[1]) segments.push({ text: boldMatch[1] });
|
|
32
|
+
segments.push({ text: boldMatch[2], bold: true });
|
|
33
|
+
remaining = boldMatch[3];
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Inline code: `text`
|
|
38
|
+
const codeMatch = remaining.match(/^(.*?)`(.+?)`(.*)/s);
|
|
39
|
+
if (codeMatch) {
|
|
40
|
+
if (codeMatch[1]) segments.push({ text: codeMatch[1] });
|
|
41
|
+
segments.push({ text: codeMatch[2], dim: true, color: 'yellow' });
|
|
42
|
+
remaining = codeMatch[3];
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// No more matches — push rest
|
|
47
|
+
segments.push({ text: remaining });
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return segments;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Render a single line of markdown as Ink elements */
|
|
55
|
+
export function renderMarkdownLine(line: string, key: string | number): React.ReactElement {
|
|
56
|
+
// Horizontal rule
|
|
57
|
+
if (/^---+$/.test(line.trim())) {
|
|
58
|
+
return <Text key={key} color="gray">{'\u2500'.repeat(Math.min(60, process.stdout.columns || 60))}</Text>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Heading: ## text
|
|
62
|
+
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
|
63
|
+
if (headingMatch) {
|
|
64
|
+
const level = headingMatch[1].length;
|
|
65
|
+
const content = headingMatch[2].replace(/\*\*/g, ''); // Strip bold in headings
|
|
66
|
+
const color = level <= 2 ? 'cyan' : 'white';
|
|
67
|
+
return <Text key={key} color={color} bold>{content}</Text>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Empty line
|
|
71
|
+
if (!line.trim()) {
|
|
72
|
+
return <Text key={key}> </Text>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Regular line with inline formatting
|
|
76
|
+
const segments = parseInline(line);
|
|
77
|
+
|
|
78
|
+
if (segments.length === 1 && !segments[0].bold && !segments[0].dim) {
|
|
79
|
+
// Simple text — no formatting needed
|
|
80
|
+
return <Text key={key} color="white">{segments[0].text}</Text>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Text key={key}>
|
|
85
|
+
{segments.map((seg, i) => (
|
|
86
|
+
<Text
|
|
87
|
+
key={i}
|
|
88
|
+
bold={seg.bold}
|
|
89
|
+
dimColor={seg.dim}
|
|
90
|
+
color={seg.color ?? 'white'}
|
|
91
|
+
>
|
|
92
|
+
{seg.text}
|
|
93
|
+
</Text>
|
|
94
|
+
))}
|
|
95
|
+
</Text>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Render multi-line markdown text as array of Ink elements */
|
|
100
|
+
export function renderMarkdown(text: string, baseKey: string | number = 0): React.ReactElement[] {
|
|
101
|
+
return text.split('\n').map((line, i) => renderMarkdownLine(line, `${baseKey}-${i}`));
|
|
102
|
+
}
|