tycono 0.1.96-beta.2 → 0.1.96-beta.21
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/README.md +106 -35
- package/bin/tycono.ts +23 -1
- package/package.json +1 -1
- package/src/api/src/routes/active-sessions.ts +3 -0
- package/src/api/src/routes/execute.ts +6 -12
- package/src/api/src/services/supervisor-heartbeat.ts +19 -0
- package/src/tui/api.ts +40 -7
- package/src/tui/app.tsx +369 -173
- package/src/tui/components/CommandMode.tsx +277 -0
- package/src/tui/components/OrgTree.tsx +4 -17
- package/src/tui/components/PanelMode.tsx +265 -0
- package/src/tui/components/SetupWizard.tsx +10 -3
- package/src/tui/components/StatusBar.tsx +44 -25
- package/src/tui/components/{StreamPanel.tsx → StreamView.tsx} +53 -73
- package/src/tui/hooks/useApi.ts +27 -6
- package/src/tui/hooks/useCommand.ts +162 -0
- package/src/tui/hooks/useSSE.ts +68 -24
- package/src/tui/store.ts +12 -0
- package/src/tui/components/CommandInput.tsx +0 -32
- package/src/tui/components/HelpOverlay.tsx +0 -51
- package/src/tui/components/SessionList.tsx +0 -74
- package/src/tui/components/WaveDialog.tsx +0 -56
- package/src/tui/hooks/useKeyboard.ts +0 -62
package/src/tui/hooks/useSSE.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useSSE — subscribe to wave SSE stream
|
|
2
|
+
* useSSE — subscribe to wave SSE stream with auto-reconnect
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
6
6
|
import { subscribeToWaveStream, type SSEEvent, type SSEConnection } from '../api';
|
|
7
7
|
|
|
8
8
|
const MAX_EVENTS = 500;
|
|
9
|
+
const RECONNECT_DELAY_MS = 3000;
|
|
10
|
+
const MAX_RECONNECT_DELAY_MS = 15000;
|
|
9
11
|
|
|
10
12
|
export interface SSEState {
|
|
11
13
|
events: SSEEvent[];
|
|
@@ -18,9 +20,13 @@ export function useSSE(waveId: string | null): SSEState {
|
|
|
18
20
|
const [streamStatus, setStreamStatus] = useState<'idle' | 'streaming' | 'done' | 'error'>('idle');
|
|
19
21
|
const connRef = useRef<SSEConnection | null>(null);
|
|
20
22
|
const waveIdRef = useRef<string | null>(null);
|
|
23
|
+
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
24
|
+
const reconnectAttemptRef = useRef(0);
|
|
25
|
+
const maxSeqRef = useRef(0);
|
|
21
26
|
|
|
22
27
|
const clearEvents = useCallback(() => {
|
|
23
28
|
setEvents([]);
|
|
29
|
+
maxSeqRef.current = 0;
|
|
24
30
|
}, []);
|
|
25
31
|
|
|
26
32
|
useEffect(() => {
|
|
@@ -29,6 +35,11 @@ export function useSSE(waveId: string | null): SSEState {
|
|
|
29
35
|
connRef.current?.close();
|
|
30
36
|
connRef.current = null;
|
|
31
37
|
waveIdRef.current = waveId;
|
|
38
|
+
reconnectAttemptRef.current = 0;
|
|
39
|
+
if (reconnectTimerRef.current) {
|
|
40
|
+
clearTimeout(reconnectTimerRef.current);
|
|
41
|
+
reconnectTimerRef.current = null;
|
|
42
|
+
}
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
if (!waveId) {
|
|
@@ -36,32 +47,65 @@ export function useSSE(waveId: string | null): SSEState {
|
|
|
36
47
|
return;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
const connect = (fromSeq: number) => {
|
|
51
|
+
setStreamStatus('streaming');
|
|
52
|
+
|
|
53
|
+
// Only clear events on first connect (not reconnects)
|
|
54
|
+
if (fromSeq === 0) {
|
|
55
|
+
setEvents([]);
|
|
56
|
+
maxSeqRef.current = 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const conn = subscribeToWaveStream(
|
|
60
|
+
waveId,
|
|
61
|
+
(event) => {
|
|
62
|
+
// Track max seq for reconnect resume
|
|
63
|
+
if (event.seq !== undefined && event.seq > maxSeqRef.current) {
|
|
64
|
+
maxSeqRef.current = event.seq;
|
|
65
|
+
}
|
|
66
|
+
reconnectAttemptRef.current = 0; // Reset on successful event
|
|
67
|
+
|
|
68
|
+
setEvents((prev) => {
|
|
69
|
+
const next = [...prev, event];
|
|
70
|
+
return next.length > MAX_EVENTS ? next.slice(-MAX_EVENTS) : next;
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
(reason) => {
|
|
74
|
+
if (reason === 'done') {
|
|
75
|
+
setStreamStatus('done');
|
|
76
|
+
} else {
|
|
77
|
+
// Disconnected or error → auto-reconnect
|
|
78
|
+
const attempt = reconnectAttemptRef.current++;
|
|
79
|
+
const delay = Math.min(
|
|
80
|
+
RECONNECT_DELAY_MS * Math.pow(1.5, attempt),
|
|
81
|
+
MAX_RECONNECT_DELAY_MS,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
setStreamStatus('streaming'); // Keep showing streaming during reconnect
|
|
41
85
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
connRef.current = conn;
|
|
86
|
+
reconnectTimerRef.current = setTimeout(() => {
|
|
87
|
+
reconnectTimerRef.current = null;
|
|
88
|
+
if (waveIdRef.current === waveId) {
|
|
89
|
+
connect(maxSeqRef.current);
|
|
90
|
+
}
|
|
91
|
+
}, delay);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
fromSeq,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
connRef.current = conn;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
connect(0);
|
|
62
101
|
|
|
63
102
|
return () => {
|
|
64
|
-
|
|
103
|
+
connRef.current?.close();
|
|
104
|
+
connRef.current = null;
|
|
105
|
+
if (reconnectTimerRef.current) {
|
|
106
|
+
clearTimeout(reconnectTimerRef.current);
|
|
107
|
+
reconnectTimerRef.current = null;
|
|
108
|
+
}
|
|
65
109
|
};
|
|
66
110
|
}, [waveId]);
|
|
67
111
|
|
package/src/tui/store.ts
CHANGED
|
@@ -97,3 +97,15 @@ export function buildOrgTree(roles: RoleInfo[], statuses: Record<string, string>
|
|
|
97
97
|
|
|
98
98
|
return roots;
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/** Flatten org tree into visual top-to-bottom order of role IDs */
|
|
102
|
+
export function flattenOrgRoleIds(nodes: OrgNode[]): string[] {
|
|
103
|
+
const result: string[] = [];
|
|
104
|
+
for (const node of nodes) {
|
|
105
|
+
result.push(node.role.id);
|
|
106
|
+
if (node.children.length > 0) {
|
|
107
|
+
result.push(...flattenOrgRoleIds(node.children));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CommandInput — bottom bar with command input and shortcut hints
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import React from 'react';
|
|
6
|
-
import { Box, Text } from 'ink';
|
|
7
|
-
|
|
8
|
-
interface CommandInputProps {
|
|
9
|
-
focused: boolean;
|
|
10
|
-
waveStatus: 'idle' | 'running' | 'done';
|
|
11
|
-
dialog: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const CommandInput: React.FC<CommandInputProps> = ({ focused, waveStatus, dialog }) => {
|
|
15
|
-
if (dialog !== 'none') return null;
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<Box width="100%" paddingX={1} justifyContent="space-between">
|
|
19
|
-
<Box>
|
|
20
|
-
<Text color="green" bold>> </Text>
|
|
21
|
-
<Text color={focused ? 'white' : 'gray'}>
|
|
22
|
-
{waveStatus === 'running' ? 'Wave running...' : 'Ready'}
|
|
23
|
-
</Text>
|
|
24
|
-
</Box>
|
|
25
|
-
<Box>
|
|
26
|
-
<Text color="gray" dimColor>
|
|
27
|
-
[w]ave [?]help [q]uit [Tab]panel
|
|
28
|
-
</Text>
|
|
29
|
-
</Box>
|
|
30
|
-
</Box>
|
|
31
|
-
);
|
|
32
|
-
};
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HelpOverlay — keyboard shortcut reference
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import React from 'react';
|
|
6
|
-
import { Box, Text, useInput } from 'ink';
|
|
7
|
-
|
|
8
|
-
interface HelpOverlayProps {
|
|
9
|
-
onClose(): void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const shortcuts = [
|
|
13
|
-
['w', 'Wave dispatch'],
|
|
14
|
-
['q', 'Quit'],
|
|
15
|
-
['?', 'Toggle help'],
|
|
16
|
-
['Tab', 'Cycle panels'],
|
|
17
|
-
['j/k', 'Navigate (in focused panel)'],
|
|
18
|
-
['Enter', 'Select'],
|
|
19
|
-
['Esc', 'Close dialog / deselect'],
|
|
20
|
-
] as const;
|
|
21
|
-
|
|
22
|
-
export const HelpOverlay: React.FC<HelpOverlayProps> = ({ onClose }) => {
|
|
23
|
-
useInput((input, key) => {
|
|
24
|
-
if (input === '?' || input === 'q' || key.escape) {
|
|
25
|
-
onClose();
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
return (
|
|
30
|
-
<Box
|
|
31
|
-
flexDirection="column"
|
|
32
|
-
borderStyle="round"
|
|
33
|
-
borderColor="yellow"
|
|
34
|
-
paddingX={2}
|
|
35
|
-
paddingY={1}
|
|
36
|
-
>
|
|
37
|
-
<Text bold color="yellow">{'\u2500\u2500'} Keyboard Shortcuts {'\u2500\u2500'}</Text>
|
|
38
|
-
<Box marginTop={1} flexDirection="column">
|
|
39
|
-
{shortcuts.map(([key, desc]) => (
|
|
40
|
-
<Box key={key}>
|
|
41
|
-
<Text color="cyan" bold>{key.padEnd(8)}</Text>
|
|
42
|
-
<Text color="white">{desc}</Text>
|
|
43
|
-
</Box>
|
|
44
|
-
))}
|
|
45
|
-
</Box>
|
|
46
|
-
<Box marginTop={1}>
|
|
47
|
-
<Text color="gray" dimColor>Press ? or Esc to close</Text>
|
|
48
|
-
</Box>
|
|
49
|
-
</Box>
|
|
50
|
-
);
|
|
51
|
-
};
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SessionList — left bottom panel showing session history
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import React from 'react';
|
|
6
|
-
import { Box, Text } from 'ink';
|
|
7
|
-
import type { SessionInfo } from '../api';
|
|
8
|
-
|
|
9
|
-
interface SessionListProps {
|
|
10
|
-
sessions: SessionInfo[];
|
|
11
|
-
focused: boolean;
|
|
12
|
-
selectedIndex: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function sourceIcon(source: string): string {
|
|
16
|
-
switch (source) {
|
|
17
|
-
case 'wave': return 'W';
|
|
18
|
-
case 'dispatch': return 'D';
|
|
19
|
-
case 'chat': return 'C';
|
|
20
|
-
case 'consult': return 'Q';
|
|
21
|
-
default: return '?';
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function statusSymbol(status: string): { icon: string; color: string } {
|
|
26
|
-
switch (status) {
|
|
27
|
-
case 'active':
|
|
28
|
-
return { icon: '\u25CF', color: 'green' };
|
|
29
|
-
case 'closed':
|
|
30
|
-
return { icon: '\u2713', color: 'gray' };
|
|
31
|
-
default:
|
|
32
|
-
return { icon: '\u25CB', color: 'gray' };
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const SessionList: React.FC<SessionListProps> = ({ sessions, focused, selectedIndex }) => {
|
|
37
|
-
// Show most recent first, limit to 10
|
|
38
|
-
const sorted = [...sessions]
|
|
39
|
-
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
40
|
-
.slice(0, 10);
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<Box flexDirection="column" paddingX={1}>
|
|
44
|
-
<Text bold color={focused ? 'cyan' : 'gray'}>
|
|
45
|
-
{'\u2500\u2500'} Sessions ({sessions.length}) {'\u2500\u2500'}
|
|
46
|
-
</Text>
|
|
47
|
-
{sorted.length === 0 && (
|
|
48
|
-
<Text color="gray" dimColor>No sessions yet</Text>
|
|
49
|
-
)}
|
|
50
|
-
{sorted.map((session, i) => {
|
|
51
|
-
const isSelected = focused && i === selectedIndex;
|
|
52
|
-
const { icon, color } = statusSymbol(session.status);
|
|
53
|
-
const title = session.title || session.roleId;
|
|
54
|
-
const src = sourceIcon(session.source);
|
|
55
|
-
|
|
56
|
-
return (
|
|
57
|
-
<Box key={session.id}>
|
|
58
|
-
<Text color={color}>{icon}</Text>
|
|
59
|
-
<Text> </Text>
|
|
60
|
-
<Text color="gray">[{src}]</Text>
|
|
61
|
-
<Text> </Text>
|
|
62
|
-
<Text
|
|
63
|
-
color={isSelected ? 'cyan' : 'white'}
|
|
64
|
-
bold={isSelected}
|
|
65
|
-
inverse={isSelected}
|
|
66
|
-
>
|
|
67
|
-
{title.length > 25 ? title.slice(0, 24) + '\u2026' : title}
|
|
68
|
-
</Text>
|
|
69
|
-
</Box>
|
|
70
|
-
);
|
|
71
|
-
})}
|
|
72
|
-
</Box>
|
|
73
|
-
);
|
|
74
|
-
};
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WaveDialog — modal for wave dispatch input
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import React, { useState } from 'react';
|
|
6
|
-
import { Box, Text, useInput } from 'ink';
|
|
7
|
-
import TextInput from 'ink-text-input';
|
|
8
|
-
|
|
9
|
-
interface WaveDialogProps {
|
|
10
|
-
onSubmit(directive: string): void;
|
|
11
|
-
onCancel(): void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const WaveDialog: React.FC<WaveDialogProps> = ({ onSubmit, onCancel }) => {
|
|
15
|
-
const [directive, setDirective] = useState('');
|
|
16
|
-
|
|
17
|
-
useInput((_input, key) => {
|
|
18
|
-
if (key.escape) {
|
|
19
|
-
onCancel();
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
const handleSubmit = (value: string) => {
|
|
24
|
-
const trimmed = value.trim();
|
|
25
|
-
if (trimmed) {
|
|
26
|
-
onSubmit(trimmed);
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<Box
|
|
32
|
-
flexDirection="column"
|
|
33
|
-
borderStyle="round"
|
|
34
|
-
borderColor="cyan"
|
|
35
|
-
paddingX={2}
|
|
36
|
-
paddingY={1}
|
|
37
|
-
>
|
|
38
|
-
<Text bold color="cyan">{'\u2500\u2500'} Wave Dispatch {'\u2500\u2500'}</Text>
|
|
39
|
-
<Box marginTop={1}>
|
|
40
|
-
<Text color="white">Directive: </Text>
|
|
41
|
-
</Box>
|
|
42
|
-
<Box marginTop={0}>
|
|
43
|
-
<Text color="green">> </Text>
|
|
44
|
-
<TextInput
|
|
45
|
-
value={directive}
|
|
46
|
-
onChange={setDirective}
|
|
47
|
-
onSubmit={handleSubmit}
|
|
48
|
-
placeholder="Type your wave directive..."
|
|
49
|
-
/>
|
|
50
|
-
</Box>
|
|
51
|
-
<Box marginTop={1}>
|
|
52
|
-
<Text color="gray" dimColor>[Enter: Dispatch] [Esc: Cancel]</Text>
|
|
53
|
-
</Box>
|
|
54
|
-
</Box>
|
|
55
|
-
);
|
|
56
|
-
};
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useKeyboard — global keyboard shortcuts for TUI
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { useInput } from 'ink';
|
|
6
|
-
|
|
7
|
-
export interface KeyboardActions {
|
|
8
|
-
onWave(): void;
|
|
9
|
-
onQuit(): void;
|
|
10
|
-
onHelp(): void;
|
|
11
|
-
onTab(): void;
|
|
12
|
-
onUp(): void;
|
|
13
|
-
onDown(): void;
|
|
14
|
-
onEnter(): void;
|
|
15
|
-
onEscape(): void;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function useKeyboard(actions: KeyboardActions, enabled: boolean): void {
|
|
19
|
-
useInput((input, key) => {
|
|
20
|
-
if (!enabled) return;
|
|
21
|
-
|
|
22
|
-
if (input === 'w') {
|
|
23
|
-
actions.onWave();
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (input === 'q') {
|
|
28
|
-
actions.onQuit();
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (input === '?') {
|
|
33
|
-
actions.onHelp();
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (key.tab) {
|
|
38
|
-
actions.onTab();
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (key.upArrow || input === 'k') {
|
|
43
|
-
actions.onUp();
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (key.downArrow || input === 'j') {
|
|
48
|
-
actions.onDown();
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (key.return) {
|
|
53
|
-
actions.onEnter();
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (key.escape) {
|
|
58
|
-
actions.onEscape();
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
}
|