tycono 0.1.96-beta.37 → 0.1.96-beta.39
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
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
|
+
// Markdown rendering is done inline via regex (no external dependency)
|
|
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,28 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
|
|
|
232
236
|
|
|
233
237
|
/** Render a single StreamLine */
|
|
234
238
|
function StreamLineRow({ line }: { line: StreamLine }) {
|
|
239
|
+
// Markdown: inline formatting only (no multi-line splitting — too many elements)
|
|
240
|
+
if (line.markdown) {
|
|
241
|
+
// Strip markdown markers for terminal display
|
|
242
|
+
const cleaned = line.text
|
|
243
|
+
.replace(/^#{1,4}\s+/gm, '') // ## heading → heading
|
|
244
|
+
.replace(/\*\*(.+?)\*\*/g, '$1') // **bold** → bold
|
|
245
|
+
.replace(/`(.+?)`/g, '$1') // `code` → code
|
|
246
|
+
.replace(/^---+$/gm, '\u2500'.repeat(40)); // --- → line
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<Box>
|
|
250
|
+
{line.indent && <Text> </Text>}
|
|
251
|
+
{line.prefix && (
|
|
252
|
+
<Text color={line.prefixColor} bold>
|
|
253
|
+
{(line.prefix).padEnd(12)}
|
|
254
|
+
</Text>
|
|
255
|
+
)}
|
|
256
|
+
<Text color={line.color} wrap="wrap">{cleaned}</Text>
|
|
257
|
+
</Box>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
235
261
|
return (
|
|
236
262
|
<Box>
|
|
237
263
|
{line.indent && <Text> </Text>}
|
|
@@ -261,19 +287,19 @@ export const CommandMode: React.FC<CommandModeProps> = ({
|
|
|
261
287
|
if (line) eventLines.push(line);
|
|
262
288
|
}
|
|
263
289
|
|
|
264
|
-
// Merge system messages and event lines (cap total
|
|
265
|
-
const allLines = [...systemMessages, ...eventLines].slice(-
|
|
290
|
+
// Merge system messages and event lines (cap total)
|
|
291
|
+
const allLines = [...systemMessages, ...eventLines].slice(-100);
|
|
266
292
|
|
|
267
293
|
// Split into committed (scrollback) and live (re-rendered)
|
|
268
294
|
const newCommitted = allLines.slice(committedRef.current);
|
|
269
|
-
if (newCommitted.length >
|
|
270
|
-
const toCommit = newCommitted.slice(0, -
|
|
295
|
+
if (newCommitted.length > 6) {
|
|
296
|
+
const toCommit = newCommitted.slice(0, -6);
|
|
271
297
|
committedRef.current += toCommit.length;
|
|
272
298
|
}
|
|
273
299
|
|
|
274
300
|
// Cap committed to prevent Static from holding too many items
|
|
275
301
|
const rawCommitted = allLines.slice(0, committedRef.current);
|
|
276
|
-
const committedLines = rawCommitted.length >
|
|
302
|
+
const committedLines = rawCommitted.length > 50 ? rawCommitted.slice(-50) : rawCommitted;
|
|
277
303
|
const liveLines = allLines.slice(committedRef.current);
|
|
278
304
|
|
|
279
305
|
const handleSubmit = useCallback((value: string) => {
|
|
@@ -91,11 +91,21 @@ function extractWaveFiles(events: SSEEvent[]): string[] {
|
|
|
91
91
|
return Array.from(files);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/** Read file preview (first N lines) */
|
|
94
|
+
/** Read file preview (first N lines, cached) */
|
|
95
|
+
const fileCache = new Map<string, string[]>();
|
|
95
96
|
function readFilePreview(filePath: string, maxLines: number): string[] {
|
|
97
|
+
const cached = fileCache.get(filePath);
|
|
98
|
+
if (cached) return cached;
|
|
96
99
|
try {
|
|
97
100
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
98
|
-
|
|
101
|
+
const lines = content.split('\n').slice(0, maxLines);
|
|
102
|
+
fileCache.set(filePath, lines);
|
|
103
|
+
// Evict old entries
|
|
104
|
+
if (fileCache.size > 5) {
|
|
105
|
+
const first = fileCache.keys().next().value;
|
|
106
|
+
if (first) fileCache.delete(first);
|
|
107
|
+
}
|
|
108
|
+
return lines;
|
|
99
109
|
} catch {
|
|
100
110
|
return ['(cannot read file)'];
|
|
101
111
|
}
|
package/src/tui/hooks/useSSE.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
9
9
|
import { subscribeToWaveStream, type SSEEvent, type SSEConnection } from '../api';
|
|
10
10
|
|
|
11
|
-
const MAX_EVENTS =
|
|
11
|
+
const MAX_EVENTS = 100;
|
|
12
12
|
const RECONNECT_DELAY_MS = 3000;
|
|
13
13
|
const MAX_RECONNECT_DELAY_MS = 15000;
|
|
14
14
|
const BATCH_INTERVAL_MS = 300; // Throttle: update React state max ~3x/sec
|
|
@@ -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
|
+
}
|