tycono 0.3.14-beta.9 → 0.3.15
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 +292 -463
- package/src/tui/components/StreamView.tsx +45 -113
|
@@ -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,296 @@ 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 [
|
|
103
|
+
const [docsFilter, setDocsFilter] = useState<'all' | 'wave' | 'kb' | 'projects'>('all');
|
|
104
|
+
const [docsPreview, setDocsPreview] = useState(false); // true = file preview mode
|
|
152
105
|
|
|
153
106
|
useEffect(() => {
|
|
154
|
-
const
|
|
155
|
-
process.stdout.on('resize',
|
|
156
|
-
return () => { process.stdout.off('resize',
|
|
107
|
+
const fn = () => setTermHeight(process.stdout.rows || 30);
|
|
108
|
+
process.stdout.on('resize', fn);
|
|
109
|
+
return () => { process.stdout.off('resize', fn); };
|
|
157
110
|
}, []);
|
|
158
111
|
|
|
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]);
|
|
112
|
+
const statuses = useMemo(() => getWaveScopedStatuses(allSessions, focusedWaveId), [allSessions, focusedWaveId]);
|
|
113
|
+
const scopedTree = useMemo(() => {
|
|
114
|
+
const scope = (n: OrgNode): OrgNode => ({ ...n, status: statuses[n.role.id] ?? 'idle', children: n.children.map(scope) });
|
|
115
|
+
return tree.map(scope);
|
|
116
|
+
}, [tree, statuses]);
|
|
184
117
|
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
interface DocEntry { path: string; title: string; isWave: boolean; }
|
|
190
|
-
const entries: DocEntry[] = [];
|
|
191
|
-
|
|
192
|
-
// Scan all .md files from COMPANY_ROOT
|
|
193
|
-
const allMdFiles = companyRoot ? scanMdFiles(companyRoot) : [];
|
|
194
|
-
|
|
195
|
-
for (const filePath of allMdFiles) {
|
|
196
|
-
const rel = filePath.replace(companyRoot + '/', '');
|
|
197
|
-
const isWave = waveFileSet.has(filePath);
|
|
198
|
-
const isKb = rel.startsWith('knowledge/');
|
|
199
|
-
const isProject = rel.startsWith('projects/');
|
|
200
|
-
|
|
201
|
-
if (docsFilter === 'wave' && !isWave) continue;
|
|
202
|
-
if (docsFilter === 'kb' && !isKb) continue;
|
|
203
|
-
if (docsFilter === 'projects' && !isProject) continue;
|
|
204
|
-
|
|
205
|
-
entries.push({ path: filePath, title: rel, isWave });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Wave-only files not already in list (e.g. code files written by agents)
|
|
209
|
-
for (const f of waveFileSet) {
|
|
210
|
-
if (!entries.some(e => e.path === f)) {
|
|
211
|
-
if (docsFilter === 'kb' || docsFilter === 'projects') continue;
|
|
212
|
-
entries.push({ path: f, title: f.split('/').pop() || f, isWave: true });
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Sort: wave files first, then alphabetical
|
|
217
|
-
entries.sort((a, b) => {
|
|
218
|
-
if (a.isWave && !b.isWave) return -1;
|
|
219
|
-
if (!a.isWave && b.isWave) return 1;
|
|
220
|
-
return a.title.localeCompare(b.title);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
return entries;
|
|
224
|
-
}, [rightTab, docsFilter, companyRoot, waveFileSet]);
|
|
225
|
-
|
|
226
|
-
const selectedDoc = docsList[docsIndex] ?? null;
|
|
227
|
-
const filePreview = useMemo(() => {
|
|
228
|
-
if (!selectedDoc || rightTab !== 'docs') return [];
|
|
229
|
-
return readFilePreview(selectedDoc.path, 60);
|
|
230
|
-
}, [selectedDoc?.path, rightTab]);
|
|
118
|
+
const focusedWave = waves.find(w => w.waveId === focusedWaveId);
|
|
119
|
+
const focusedWaveIndex = focusedWaveId ? waves.findIndex(w => w.waveId === focusedWaveId) + 1 : 0;
|
|
120
|
+
const waveSessionCount = focusedWaveId ? allSessions.filter(s => s.waveId === focusedWaveId).length : 0;
|
|
231
121
|
|
|
122
|
+
// Key handling
|
|
232
123
|
useInput((input, key) => {
|
|
233
|
-
if (key.escape) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
124
|
+
if (key.escape) {
|
|
125
|
+
if (docsPreview) { setDocsPreview(false); return; }
|
|
126
|
+
onEscape(); return;
|
|
127
|
+
}
|
|
128
|
+
if (input === 'h' || key.leftArrow) {
|
|
237
129
|
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
238
130
|
const idx = tabs.indexOf(rightTab);
|
|
239
|
-
if (idx > 0)
|
|
131
|
+
if (idx > 0) setRightTab(tabs[idx - 1]);
|
|
240
132
|
return;
|
|
241
133
|
}
|
|
242
|
-
if (input === 'l' ||
|
|
134
|
+
if (input === 'l' || key.rightArrow) {
|
|
243
135
|
const tabs: RightTab[] = ['stream', 'docs', 'info'];
|
|
244
136
|
const idx = tabs.indexOf(rightTab);
|
|
245
|
-
if (idx < tabs.length - 1)
|
|
137
|
+
if (idx < tabs.length - 1) setRightTab(tabs[idx + 1]);
|
|
246
138
|
return;
|
|
247
139
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
} else if (rightTab === 'stream') {
|
|
258
|
-
onMove('up');
|
|
259
|
-
}
|
|
140
|
+
// j/k context-dependent
|
|
141
|
+
if (input === 'k' || key.upArrow) {
|
|
142
|
+
if (rightTab === 'docs') { setDocsIndex(i => Math.max(0, i - 1)); }
|
|
143
|
+
else { onMove('up'); }
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (input === 'j' || key.downArrow) {
|
|
147
|
+
if (rightTab === 'docs') { setDocsIndex(i => i + 1); } // capped later by docsList length
|
|
148
|
+
else { onMove('down'); }
|
|
260
149
|
return;
|
|
261
150
|
}
|
|
262
|
-
if (key.
|
|
263
|
-
if (rightTab === 'docs') {
|
|
264
|
-
if (
|
|
265
|
-
|
|
151
|
+
if (key.return) {
|
|
152
|
+
if (rightTab === 'docs' && selectedDocPath) {
|
|
153
|
+
if (docsPreview) {
|
|
154
|
+
// In preview → open in vim
|
|
155
|
+
try {
|
|
156
|
+
const editor = process.env.EDITOR || 'vim';
|
|
157
|
+
execSync(`${editor} "${selectedDocPath}"`, { stdio: 'inherit' });
|
|
158
|
+
} catch { /* ignore */ }
|
|
159
|
+
setDocsPreview(false);
|
|
266
160
|
} else {
|
|
267
|
-
|
|
161
|
+
// In list → toggle preview
|
|
162
|
+
setDocsPreview(true);
|
|
268
163
|
}
|
|
269
|
-
|
|
270
|
-
|
|
164
|
+
return;
|
|
165
|
+
} else {
|
|
166
|
+
onSelect();
|
|
271
167
|
}
|
|
272
168
|
return;
|
|
273
169
|
}
|
|
274
|
-
|
|
275
|
-
// Docs filter: 1-4
|
|
170
|
+
// Docs filter 1-4
|
|
276
171
|
if (rightTab === 'docs') {
|
|
277
|
-
const filters
|
|
172
|
+
const filters = ['all', 'wave', 'kb', 'projects'] as const;
|
|
278
173
|
const fi = parseInt(input, 10);
|
|
279
174
|
if (fi >= 1 && fi <= 4) {
|
|
280
175
|
setDocsFilter(filters[fi - 1]);
|
|
281
176
|
setDocsIndex(0);
|
|
282
|
-
|
|
177
|
+
setDocsPreview(false);
|
|
283
178
|
return;
|
|
284
179
|
}
|
|
285
180
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
setDocsScroll(0);
|
|
291
|
-
return;
|
|
181
|
+
// Wave switch 1-9 (not in docs filter mode)
|
|
182
|
+
const num = parseInt(input, 10);
|
|
183
|
+
if (rightTab !== 'docs' && num >= 1 && num <= 9 && num <= waves.length) {
|
|
184
|
+
onFocusWave(waves[num - 1].waveId);
|
|
292
185
|
}
|
|
186
|
+
});
|
|
293
187
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
188
|
+
const leftWidth = 28;
|
|
189
|
+
const termCols = process.stdout.columns || 120;
|
|
190
|
+
const rightWidth = termCols - leftWidth - 3;
|
|
191
|
+
const headerLines = 2;
|
|
192
|
+
const footerLines = 3;
|
|
193
|
+
const contentHeight = Math.max(termHeight - headerLines - footerLines, 5);
|
|
194
|
+
|
|
195
|
+
// === Build left column: OrgTree ===
|
|
196
|
+
const ceoIcon = statuses['ceo'] === 'working' ? '\u25CF' : statuses['ceo'] === 'done' ? '\u2713' : '\u25CB';
|
|
197
|
+
const isCeoSelected = flatRoles[selectedRoleIndex] === 'ceo';
|
|
198
|
+
const treeEntries = flattenTree(scopedTree);
|
|
199
|
+
|
|
200
|
+
const leftLines: Array<{ text: string; selected: boolean; working: boolean }> = [
|
|
201
|
+
{ text: `${ceoIcon} CEO`, selected: isCeoSelected, working: statuses['ceo'] === 'working' },
|
|
202
|
+
...treeEntries.map(e => ({
|
|
203
|
+
text: e.line,
|
|
204
|
+
selected: e.roleId === flatRoles[selectedRoleIndex],
|
|
205
|
+
working: e.status === 'working',
|
|
206
|
+
})),
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
// Derive selectedRoleId from index (more reliable than prop — avoids sync issues)
|
|
210
|
+
const activeRoleId = flatRoles[selectedRoleIndex] ?? null;
|
|
211
|
+
|
|
212
|
+
// === Build right column: Stream/Info/Docs ===
|
|
213
|
+
const rightContentLines: string[] = [];
|
|
214
|
+
let selectedDocPath: string | null = null;
|
|
215
|
+
if (rightTab === 'stream') {
|
|
216
|
+
if (activeRoleId) rightContentLines.push(`\u25B8 ${activeRoleId}`);
|
|
217
|
+
const maxEv = Math.max(5, contentHeight - 3);
|
|
218
|
+
const filtered = activeRoleId ? events.filter(e => e.roleId === activeRoleId) : events;
|
|
219
|
+
const visible = filtered.slice(-maxEv);
|
|
220
|
+
for (const ev of visible) {
|
|
221
|
+
const line = eventLine(ev);
|
|
222
|
+
if (!line) continue;
|
|
223
|
+
// Split multi-line text events into separate lines (preserves markdown)
|
|
224
|
+
const sublines = line.split('\n');
|
|
225
|
+
for (const sl of sublines) {
|
|
226
|
+
if (rightContentLines.length >= maxEv) break;
|
|
227
|
+
rightContentLines.push(sl.slice(0, rightWidth));
|
|
303
228
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
229
|
+
}
|
|
230
|
+
if (rightContentLines.length === 0) {
|
|
231
|
+
if (activeRoleId && events.length > 0) {
|
|
232
|
+
rightContentLines.push(`No events for ${activeRoleId} (${events.length} total)`);
|
|
233
|
+
rightContentLines.push('Press Enter to show all roles');
|
|
234
|
+
} else {
|
|
235
|
+
rightContentLines.push(waveId ? `Waiting for events... (${events.length} in buffer)` : 'No active stream. Type a directive to start.');
|
|
307
236
|
}
|
|
308
237
|
}
|
|
238
|
+
} else if (rightTab === 'info') {
|
|
239
|
+
rightContentLines.push(`Wave: ${focusedWave?.waveId ?? 'none'}`);
|
|
240
|
+
rightContentLines.push(`Directive: ${focusedWave?.directive?.slice(0, rightWidth - 12) || '(idle)'}`);
|
|
241
|
+
rightContentLines.push(`Sessions: ${waveSessionCount} Events: ${events.length}`);
|
|
242
|
+
rightContentLines.push(`Stream: ${streamStatus}`);
|
|
243
|
+
} else if (rightTab === 'docs') {
|
|
244
|
+
// Docs: scan + filter + ★ wave artifacts + preview
|
|
245
|
+
try {
|
|
246
|
+
const skipDirs = new Set(['.git', 'node_modules', '.tycono', '.worktrees', 'dist', '.claude', '.obsidian']);
|
|
247
|
+
const allMdFiles: Array<{ rel: string; full: string }> = [];
|
|
248
|
+
const walk = (dir: string, depth: number) => {
|
|
249
|
+
if (depth > 3 || allMdFiles.length > 300) return;
|
|
250
|
+
try {
|
|
251
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
252
|
+
if (skipDirs.has(e.name)) continue;
|
|
253
|
+
const full = path.join(dir, e.name);
|
|
254
|
+
if (e.isDirectory()) walk(full, depth + 1);
|
|
255
|
+
else if (e.name.endsWith('.md')) {
|
|
256
|
+
allMdFiles.push({ rel: full.replace(companyRoot + '/', ''), full });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch {}
|
|
260
|
+
};
|
|
261
|
+
walk(companyRoot, 0);
|
|
262
|
+
allMdFiles.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
263
|
+
|
|
264
|
+
// Wave artifact files (from SSE events)
|
|
265
|
+
const waveFiles = new Set<string>();
|
|
266
|
+
for (const ev of events) {
|
|
267
|
+
if (ev.type === 'tool:start') {
|
|
268
|
+
const name = (ev.data.name as string) ?? '';
|
|
269
|
+
const inp = ev.data.input as Record<string, unknown> | undefined;
|
|
270
|
+
if (['Write', 'Edit', 'NotebookEdit'].includes(name) && inp?.file_path) {
|
|
271
|
+
waveFiles.add(String(inp.file_path));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
309
275
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
276
|
+
// Apply filter
|
|
277
|
+
const filtered = allMdFiles.filter(f => {
|
|
278
|
+
if (docsFilter === 'wave') return waveFiles.has(f.full);
|
|
279
|
+
if (docsFilter === 'kb') return f.rel.startsWith('knowledge/');
|
|
280
|
+
if (docsFilter === 'projects') return f.rel.startsWith('projects/');
|
|
281
|
+
return true; // 'all'
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Sort: wave files first
|
|
285
|
+
filtered.sort((a, b) => {
|
|
286
|
+
const aw = waveFiles.has(a.full) ? 0 : 1;
|
|
287
|
+
const bw = waveFiles.has(b.full) ? 0 : 1;
|
|
288
|
+
if (aw !== bw) return aw - bw;
|
|
289
|
+
return a.rel.localeCompare(b.rel);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Cap docsIndex
|
|
293
|
+
const cappedIdx = Math.min(docsIndex, Math.max(0, filtered.length - 1));
|
|
294
|
+
if (cappedIdx !== docsIndex) setDocsIndex(cappedIdx);
|
|
295
|
+
selectedDocPath = filtered[cappedIdx]?.full ?? null;
|
|
296
|
+
|
|
297
|
+
if (docsPreview && selectedDocPath) {
|
|
298
|
+
// === Preview mode ===
|
|
299
|
+
const previewLines: string[] = [];
|
|
300
|
+
try {
|
|
301
|
+
const content = fs.readFileSync(selectedDocPath, 'utf-8');
|
|
302
|
+
previewLines.push(...content.split('\n').slice(0, contentHeight - 3));
|
|
303
|
+
} catch { previewLines.push('(cannot read)'); }
|
|
304
|
+
const shortName = selectedDocPath.split('/').slice(-2).join('/');
|
|
305
|
+
rightContentLines.push(`${waveFiles.has(selectedDocPath) ? '\u2605 ' : ''}${shortName} [Esc] back [Enter] ${process.env.EDITOR || 'vim'}`);
|
|
306
|
+
rightContentLines.push('\u2500'.repeat(Math.min(50, rightWidth)));
|
|
307
|
+
for (const pl of previewLines) {
|
|
308
|
+
if (rightContentLines.length >= contentHeight) break;
|
|
309
|
+
rightContentLines.push(pl.slice(0, rightWidth));
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
// === List mode ===
|
|
313
|
+
const filterLabels = ['1:All', '2:\u2605Wave', '3:KB', '4:Projects'];
|
|
314
|
+
const filterBar = filterLabels.map((f, i) => {
|
|
315
|
+
const key = ['all', 'wave', 'kb', 'projects'][i];
|
|
316
|
+
return key === docsFilter ? `[${f}]` : ` ${f} `;
|
|
317
|
+
}).join(' ');
|
|
318
|
+
rightContentLines.push(`${filterBar} ${filtered.length} docs [j/k] browse [Enter] preview`);
|
|
319
|
+
|
|
320
|
+
const maxVisible = Math.max(5, contentHeight - 3);
|
|
321
|
+
const scrollStart = Math.max(0, Math.min(cappedIdx - 3, filtered.length - maxVisible));
|
|
322
|
+
for (let i = scrollStart; i < Math.min(scrollStart + maxVisible, filtered.length); i++) {
|
|
323
|
+
const selected = i === cappedIdx;
|
|
324
|
+
const isWave = waveFiles.has(filtered[i].full);
|
|
325
|
+
const prefix = selected ? '\u25B6 ' : ' ';
|
|
326
|
+
const star = isWave ? '\u2605' : ' ';
|
|
327
|
+
rightContentLines.push(`${prefix}${star} ${filtered[i].rel.slice(0, rightWidth - 6)}`);
|
|
328
|
+
}
|
|
315
329
|
}
|
|
330
|
+
} catch {
|
|
331
|
+
rightContentLines.push('Cannot scan documents');
|
|
316
332
|
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Filter events for selected role
|
|
320
|
-
const roleEvents = selectedRoleId
|
|
321
|
-
? events.filter((e) => e.roleId === selectedRoleId)
|
|
322
|
-
: events;
|
|
323
|
-
|
|
324
|
-
const roleLabel = selectedRoleId
|
|
325
|
-
? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
|
|
326
|
-
: 'All';
|
|
327
|
-
|
|
328
|
-
const selectedSession = selectedRoleId
|
|
329
|
-
? findSessionForRole(activeSessions, allSessions, selectedRoleId, focusedWaveId)
|
|
330
|
-
: null;
|
|
333
|
+
}
|
|
331
334
|
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
// === Merge left + right, cap to terminal height ===
|
|
336
|
+
const maxRows = contentHeight;
|
|
337
|
+
|
|
338
|
+
const rows: Array<{ left: string; right: string; leftSelected: boolean; leftWorking: boolean }> = [];
|
|
339
|
+
for (let i = 0; i < maxRows; i++) {
|
|
340
|
+
const ll = leftLines[i];
|
|
341
|
+
rows.push({
|
|
342
|
+
left: (ll?.text ?? '').padEnd(leftWidth).slice(0, leftWidth),
|
|
343
|
+
right: rightContentLines[i] ?? '',
|
|
344
|
+
leftSelected: ll?.selected ?? false,
|
|
345
|
+
leftWorking: ll?.working ?? false,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
336
348
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
:
|
|
349
|
+
// Tab bar
|
|
350
|
+
const tabBar = ['Stream', 'Docs', 'Info'].map(t =>
|
|
351
|
+
t.toLowerCase() === rightTab ? `[${t}]` : ` ${t} `
|
|
352
|
+
).join(' ');
|
|
340
353
|
|
|
341
|
-
//
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const wavePath = path.join(companyRoot, 'operations', 'waves', `${focusedWaveId}.json`);
|
|
346
|
-
if (fs.existsSync(wavePath)) {
|
|
347
|
-
const data = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
|
|
348
|
-
return data.preset as string | undefined;
|
|
349
|
-
}
|
|
350
|
-
} catch { /* ignore */ }
|
|
351
|
-
return null;
|
|
352
|
-
}, [focusedWaveId, companyRoot]);
|
|
354
|
+
// Wave tabs
|
|
355
|
+
const waveTabs = waves.length > 1
|
|
356
|
+
? waves.map((w, i) => w.waveId === focusedWaveId ? `[${i + 1}]` : ` ${i + 1} `).join(' ')
|
|
357
|
+
: '';
|
|
353
358
|
|
|
354
|
-
const
|
|
359
|
+
const sep = '\u2500'.repeat(Math.min(termCols, 160));
|
|
360
|
+
const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming' : streamStatus === 'done' ? '\u2713 done' : 'idle';
|
|
355
361
|
|
|
356
362
|
return (
|
|
357
|
-
<Box flexDirection="column"
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
<
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
)}
|
|
455
|
-
|
|
456
|
-
{/* Docs tab — KB browser + wave artifacts */}
|
|
457
|
-
{rightTab === 'docs' && (
|
|
458
|
-
<Box flexDirection="column" paddingX={1} flexGrow={1}>
|
|
459
|
-
{/* Filter bar */}
|
|
460
|
-
<Box marginBottom={0}>
|
|
461
|
-
{(['all', 'wave', 'kb', 'projects'] as DocsFilter[]).map((f, i) => (
|
|
462
|
-
<Box key={f} marginRight={1}>
|
|
463
|
-
<Text
|
|
464
|
-
color={docsFilter === f ? 'cyan' : 'gray'}
|
|
465
|
-
bold={docsFilter === f}
|
|
466
|
-
inverse={docsFilter === f}
|
|
467
|
-
>
|
|
468
|
-
{f === 'wave' ? ` ${i + 1}:\u2605Wave ` : ` ${i + 1}:${f.charAt(0).toUpperCase() + f.slice(1)} `}
|
|
469
|
-
</Text>
|
|
470
|
-
</Box>
|
|
471
|
-
))}
|
|
472
|
-
<Text color="gray" dimColor> ({docsList.length})</Text>
|
|
473
|
-
</Box>
|
|
474
|
-
|
|
475
|
-
{docsList.length === 0 ? (
|
|
476
|
-
<Box marginTop={1}>
|
|
477
|
-
<Text color="gray">{docsFilter === 'wave' ? 'No files created in this wave.' : 'No documents found.'}</Text>
|
|
478
|
-
</Box>
|
|
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
|
-
)}
|
|
507
|
-
|
|
508
|
-
<Box marginTop={0}>
|
|
509
|
-
<Text color="gray" dimColor>
|
|
510
|
-
[Enter] {process.env.EDITOR || 'vim'} | [j/k] {docsScroll > 0 ? 'scroll' : 'select'}
|
|
511
|
-
</Text>
|
|
512
|
-
</Box>
|
|
513
|
-
</Box>
|
|
514
|
-
)}
|
|
515
|
-
</Box>
|
|
516
|
-
)}
|
|
517
|
-
|
|
518
|
-
{/* Info tab */}
|
|
519
|
-
{rightTab === 'info' && (
|
|
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>
|
|
529
|
-
|
|
530
|
-
{/* Active sessions in this wave */}
|
|
531
|
-
{waveSessionCount > 0 && (
|
|
532
|
-
<>
|
|
533
|
-
<Text color="gray" bold>{'\n'}Active in this wave:</Text>
|
|
534
|
-
{allSessions
|
|
535
|
-
.filter(s => s.waveId === focusedWaveId && s.status === 'active')
|
|
536
|
-
.slice(0, 10)
|
|
537
|
-
.map(s => {
|
|
538
|
-
const port = activeSessions.find(a => a.sessionId === s.id);
|
|
539
|
-
return (
|
|
540
|
-
<Text key={s.id} color="white">
|
|
541
|
-
{` ${s.roleId.padEnd(12)} ${s.id.slice(0, 20)} ${port ? `API:${port.ports.api}` : ''}`}
|
|
542
|
-
</Text>
|
|
543
|
-
);
|
|
544
|
-
})
|
|
545
|
-
}
|
|
546
|
-
</>
|
|
547
|
-
)}
|
|
548
|
-
</Box>
|
|
549
|
-
)}
|
|
550
|
-
</Box>
|
|
551
|
-
</Box>
|
|
363
|
+
<Box flexDirection="column">
|
|
364
|
+
{/* Header */}
|
|
365
|
+
<Text>
|
|
366
|
+
<Text color="green" bold>{'W' + focusedWaveIndex}</Text>
|
|
367
|
+
<Text color="white">{' ' + (focusedWave?.directive?.slice(0, 40) || '(idle)')}</Text>
|
|
368
|
+
<Text color="gray">{' \u2502 '}</Text>
|
|
369
|
+
<Text color="cyan" bold>{tabBar}</Text>
|
|
370
|
+
<Text color="gray">{' ' + (waveSessionCount > 0 ? waveSessionCount + ' sessions' : '') + ' '}</Text>
|
|
371
|
+
<Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>{statusLabel}</Text>
|
|
372
|
+
</Text>
|
|
373
|
+
<Text color="gray">{sep}</Text>
|
|
374
|
+
|
|
375
|
+
{/* Content rows: left (OrgTree) │ right (Stream/Info) */}
|
|
376
|
+
{rows.map((row, i) => (
|
|
377
|
+
<Text key={i}>
|
|
378
|
+
<Text color={row.leftSelected ? 'cyan' : row.leftWorking ? 'green' : 'white'} bold={row.leftSelected} inverse={row.leftSelected}>{row.left}</Text>
|
|
379
|
+
<Text color="gray">{' \u2502 '}</Text>
|
|
380
|
+
<Text color="white">{row.right}</Text>
|
|
381
|
+
</Text>
|
|
382
|
+
))}
|
|
552
383
|
|
|
553
384
|
{/* Footer */}
|
|
554
|
-
<
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
<Text color="gray" dimColor>
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
</Text>
|
|
562
|
-
</Box>
|
|
385
|
+
<Text color="gray">{sep}</Text>
|
|
386
|
+
<Text>
|
|
387
|
+
{waveTabs ? <Text color="gray">{waveTabs + ' '}</Text> : null}
|
|
388
|
+
<Text color="gray" dimColor>{rightTab === 'docs' ? '[h/l] tab [j/k] browse [Enter] open ' : '[h/l] tab [j/k] role [Enter] filter '}</Text>
|
|
389
|
+
{waves.length > 1 ? <Text color="gray" dimColor>{'[1-9] wave '}</Text> : null}
|
|
390
|
+
<Text color="gray" dimColor>{'[Esc] back'}</Text>
|
|
391
|
+
</Text>
|
|
563
392
|
</Box>
|
|
564
393
|
);
|
|
565
394
|
};
|