tycono 0.1.96-beta.0 → 0.1.96-beta.1

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.96-beta.0",
3
+ "version": "0.1.96-beta.1",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "src/api/src/",
12
12
  "src/api/package.json",
13
13
  "src/shared/",
14
+ "src/tui/",
14
15
  "src/web/dist/",
15
16
  "templates/"
16
17
  ],
package/src/tui/api.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * TUI API Client — HTTP + SSE for communicating with Tycono API server
3
+ */
4
+
5
+ import http from 'node:http';
6
+
7
+ let BASE_URL = 'http://localhost:3000';
8
+
9
+ export function setBaseUrl(url: string): void {
10
+ BASE_URL = url.replace(/\/$/, '');
11
+ }
12
+
13
+ export function getBaseUrl(): string {
14
+ return BASE_URL;
15
+ }
16
+
17
+ /* ─── HTTP helpers ─── */
18
+
19
+ async function fetchJson<T>(path: string, options?: { method?: string; body?: unknown }): Promise<T> {
20
+ const url = `${BASE_URL}${path}`;
21
+ const method = options?.method ?? 'GET';
22
+ const bodyStr = options?.body ? JSON.stringify(options.body) : undefined;
23
+
24
+ return new Promise((resolve, reject) => {
25
+ const urlObj = new URL(url);
26
+ const req = http.request(
27
+ {
28
+ hostname: urlObj.hostname,
29
+ port: urlObj.port,
30
+ path: urlObj.pathname + urlObj.search,
31
+ method,
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ ...(bodyStr ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {}),
35
+ },
36
+ },
37
+ (res) => {
38
+ let data = '';
39
+ res.on('data', (chunk) => { data += chunk; });
40
+ res.on('end', () => {
41
+ try {
42
+ resolve(JSON.parse(data) as T);
43
+ } catch {
44
+ reject(new Error(`Invalid JSON from ${path}: ${data.slice(0, 200)}`));
45
+ }
46
+ });
47
+ },
48
+ );
49
+ req.on('error', reject);
50
+ if (bodyStr) req.write(bodyStr);
51
+ req.end();
52
+ });
53
+ }
54
+
55
+ /* ─── API Types ─── */
56
+
57
+ export interface RoleInfo {
58
+ id: string;
59
+ name: string;
60
+ level: string;
61
+ reportsTo: string;
62
+ status: string;
63
+ }
64
+
65
+ export interface CompanyInfo {
66
+ name: string;
67
+ domain: string;
68
+ founded: string;
69
+ mission: string;
70
+ roles: RoleInfo[];
71
+ }
72
+
73
+ export interface SessionInfo {
74
+ id: string;
75
+ roleId: string;
76
+ title: string;
77
+ mode: string;
78
+ status: string;
79
+ source: string;
80
+ waveId?: string;
81
+ createdAt: string;
82
+ }
83
+
84
+ export interface ExecStatus {
85
+ statuses: Record<string, string>;
86
+ activeExecutions: Array<{
87
+ id: string;
88
+ roleId: string;
89
+ task: string;
90
+ startedAt: string;
91
+ }>;
92
+ }
93
+
94
+ export interface WaveResponse {
95
+ waveId: string;
96
+ supervisorSessionId?: string;
97
+ mode: string;
98
+ directive: string;
99
+ }
100
+
101
+ export interface SSEEvent {
102
+ seq: number;
103
+ ts: string;
104
+ type: string;
105
+ roleId: string;
106
+ data: Record<string, unknown>;
107
+ }
108
+
109
+ /* ─── API calls ─── */
110
+
111
+ export async function fetchCompany(): Promise<CompanyInfo> {
112
+ return fetchJson<CompanyInfo>('/api/company');
113
+ }
114
+
115
+ export async function fetchRoles(): Promise<RoleInfo[]> {
116
+ return fetchJson<RoleInfo[]>('/api/roles');
117
+ }
118
+
119
+ export async function fetchSessions(): Promise<SessionInfo[]> {
120
+ return fetchJson<SessionInfo[]>('/api/sessions');
121
+ }
122
+
123
+ export async function fetchExecStatus(): Promise<ExecStatus> {
124
+ return fetchJson<ExecStatus>('/api/exec/status');
125
+ }
126
+
127
+ export async function dispatchWave(directive: string, options?: {
128
+ targetRoles?: string[];
129
+ continuous?: boolean;
130
+ }): Promise<WaveResponse> {
131
+ return fetchJson<WaveResponse>('/api/jobs', {
132
+ method: 'POST',
133
+ body: {
134
+ type: 'wave',
135
+ directive,
136
+ targetRoles: options?.targetRoles,
137
+ continuous: options?.continuous ?? false,
138
+ },
139
+ });
140
+ }
141
+
142
+ export async function sendDirective(waveId: string, text: string): Promise<{ ok: boolean }> {
143
+ return fetchJson<{ ok: boolean }>(`/api/waves/${waveId}/directive`, {
144
+ method: 'POST',
145
+ body: { text },
146
+ });
147
+ }
148
+
149
+ export async function fetchActiveWaves(): Promise<{ waves: Array<{ waveId: string; sessionIds: string[] }> }> {
150
+ return fetchJson('/api/waves/active');
151
+ }
152
+
153
+ /* ─── SSE stream ─── */
154
+
155
+ export interface SSEConnection {
156
+ close(): void;
157
+ }
158
+
159
+ export function subscribeToWaveStream(
160
+ waveId: string,
161
+ onEvent: (event: SSEEvent) => void,
162
+ onEnd?: (reason: string) => void,
163
+ fromSeq?: number,
164
+ ): SSEConnection {
165
+ const url = new URL(`${BASE_URL}/api/waves/${waveId}/stream`);
166
+ if (fromSeq) url.searchParams.set('from', String(fromSeq));
167
+
168
+ let destroyed = false;
169
+ let req: http.ClientRequest | null = null;
170
+
171
+ const connect = () => {
172
+ req = http.get(url.toString(), (res) => {
173
+ let buffer = '';
174
+
175
+ res.on('data', (chunk: Buffer) => {
176
+ if (destroyed) return;
177
+ buffer += chunk.toString();
178
+
179
+ // Parse SSE format
180
+ const parts = buffer.split('\n\n');
181
+ buffer = parts.pop() ?? '';
182
+
183
+ for (const part of parts) {
184
+ if (!part.trim() || part.startsWith(':')) continue;
185
+
186
+ const lines = part.split('\n');
187
+ let eventType = '';
188
+ let data = '';
189
+
190
+ for (const line of lines) {
191
+ if (line.startsWith('event: ')) {
192
+ eventType = line.slice(7);
193
+ } else if (line.startsWith('data: ')) {
194
+ data = line.slice(6);
195
+ }
196
+ }
197
+
198
+ if (eventType === 'activity' && data) {
199
+ try {
200
+ onEvent(JSON.parse(data) as SSEEvent);
201
+ } catch { /* ignore parse errors */ }
202
+ } else if (eventType === 'stream:end' && data) {
203
+ try {
204
+ const parsed = JSON.parse(data);
205
+ onEnd?.(parsed.reason ?? 'unknown');
206
+ } catch {
207
+ onEnd?.('unknown');
208
+ }
209
+ }
210
+ }
211
+ });
212
+
213
+ res.on('end', () => {
214
+ if (!destroyed) {
215
+ onEnd?.('disconnected');
216
+ }
217
+ });
218
+
219
+ res.on('error', () => {
220
+ if (!destroyed) {
221
+ onEnd?.('error');
222
+ }
223
+ });
224
+ });
225
+
226
+ req.on('error', () => {
227
+ if (!destroyed) {
228
+ onEnd?.('error');
229
+ }
230
+ });
231
+ };
232
+
233
+ connect();
234
+
235
+ return {
236
+ close() {
237
+ destroyed = true;
238
+ req?.destroy();
239
+ },
240
+ };
241
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * TUI App — main layout with 4 panels
3
+ *
4
+ * Layout:
5
+ * ┌─────────────────────────────────────────┐
6
+ * │ StatusBar │
7
+ * ├──────────────┬──────────────────────────┤
8
+ * │ OrgTree │ StreamPanel │
9
+ * │ │ │
10
+ * ├──────────────┤ │
11
+ * │ SessionList │ │
12
+ * ├──────────────┴──────────────────────────┤
13
+ * │ CommandInput │
14
+ * └─────────────────────────────────────────┘
15
+ */
16
+
17
+ import React, { useState, useCallback, useMemo } from 'react';
18
+ import { Box, Text, useApp } from 'ink';
19
+ import { StatusBar } from './components/StatusBar';
20
+ import { OrgTree } from './components/OrgTree';
21
+ import { SessionList } from './components/SessionList';
22
+ import { StreamPanel } from './components/StreamPanel';
23
+ import { CommandInput } from './components/CommandInput';
24
+ import { WaveDialog } from './components/WaveDialog';
25
+ import { HelpOverlay } from './components/HelpOverlay';
26
+ import { useApi } from './hooks/useApi';
27
+ import { useSSE } from './hooks/useSSE';
28
+ import { useKeyboard } from './hooks/useKeyboard';
29
+ import { buildOrgTree } from './store';
30
+ import { dispatchWave } from './api';
31
+
32
+ type Panel = 'org' | 'sessions' | 'stream' | 'command';
33
+ type Dialog = 'none' | 'wave' | 'help';
34
+
35
+ const PANELS: Panel[] = ['org', 'sessions', 'stream', 'command'];
36
+
37
+ export const App: React.FC = () => {
38
+ const { exit } = useApp();
39
+ const api = useApi();
40
+
41
+ const [activePanel, setActivePanel] = useState<Panel>('org');
42
+ const [dialog, setDialog] = useState<Dialog>('none');
43
+ const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
44
+ const [selectedSessionIndex, setSelectedSessionIndex] = useState(0);
45
+ const [waveId, setWaveId] = useState<string | null>(null);
46
+ const [waveStatus, setWaveStatus] = useState<'idle' | 'running' | 'done'>('idle');
47
+
48
+ // Derive active wave from API if we don't have one
49
+ const effectiveWaveId = waveId ?? api.activeWaveId;
50
+
51
+ const sse = useSSE(effectiveWaveId);
52
+
53
+ // Build org tree
54
+ const roles = api.company?.roles ?? [];
55
+ const flatRoleIds = useMemo(() => roles.map(r => r.id), [roles]);
56
+ const statuses = api.execStatus?.statuses ?? {};
57
+ const orgTree = useMemo(() => buildOrgTree(roles, statuses), [roles, statuses]);
58
+
59
+ // Count active
60
+ const activeCount = Object.values(statuses).filter(s => s === 'working' || s === 'streaming').length;
61
+
62
+ // Determine wave status from SSE
63
+ const derivedWaveStatus = useMemo(() => {
64
+ if (sse.streamStatus === 'streaming') return 'running' as const;
65
+ if (sse.streamStatus === 'done') return 'done' as const;
66
+ if (waveStatus === 'running' && activeCount > 0) return 'running' as const;
67
+ return waveStatus;
68
+ }, [sse.streamStatus, waveStatus, activeCount]);
69
+
70
+ // Handle wave dispatch
71
+ const handleWaveSubmit = useCallback(async (directive: string) => {
72
+ setDialog('none');
73
+ try {
74
+ const result = await dispatchWave(directive);
75
+ setWaveId(result.waveId);
76
+ setWaveStatus('running');
77
+ sse.clearEvents();
78
+ api.refresh();
79
+ } catch (err) {
80
+ // Show error briefly
81
+ console.error('Wave dispatch failed:', err);
82
+ }
83
+ }, [sse, api]);
84
+
85
+ // Keyboard actions — disabled when dialog is open
86
+ const keyboardEnabled = dialog === 'none';
87
+
88
+ useKeyboard({
89
+ onWave: () => setDialog('wave'),
90
+ onQuit: () => exit(),
91
+ onHelp: () => setDialog(dialog === 'help' ? 'none' : 'help'),
92
+ onTab: () => {
93
+ const idx = PANELS.indexOf(activePanel);
94
+ setActivePanel(PANELS[(idx + 1) % PANELS.length]);
95
+ },
96
+ onUp: () => {
97
+ if (activePanel === 'org') {
98
+ setSelectedRoleIndex(Math.max(0, selectedRoleIndex - 1));
99
+ } else if (activePanel === 'sessions') {
100
+ setSelectedSessionIndex(Math.max(0, selectedSessionIndex - 1));
101
+ }
102
+ },
103
+ onDown: () => {
104
+ if (activePanel === 'org') {
105
+ setSelectedRoleIndex(Math.min(flatRoleIds.length - 1, selectedRoleIndex + 1));
106
+ } else if (activePanel === 'sessions') {
107
+ setSelectedSessionIndex(Math.min(Math.max(0, api.sessions.length - 1), selectedSessionIndex + 1));
108
+ }
109
+ },
110
+ onEnter: () => {
111
+ // Future: select role/session to show in stream
112
+ },
113
+ onEscape: () => {
114
+ if (dialog !== 'none') {
115
+ setDialog('none');
116
+ }
117
+ },
118
+ }, keyboardEnabled);
119
+
120
+ // Error display
121
+ if (api.error) {
122
+ return (
123
+ <Box flexDirection="column" paddingX={1}>
124
+ <Text color="cyan" bold>TYCONO TUI</Text>
125
+ <Text color="red">API Error: {api.error}</Text>
126
+ <Text color="gray">Make sure the API server is running on the configured port.</Text>
127
+ <Text color="gray" dimColor>Press q to quit</Text>
128
+ </Box>
129
+ );
130
+ }
131
+
132
+ // Help overlay
133
+ if (dialog === 'help') {
134
+ return (
135
+ <Box flexDirection="column">
136
+ <StatusBar
137
+ companyName={api.company?.name ?? 'Loading...'}
138
+ waveId={effectiveWaveId}
139
+ waveStatus={derivedWaveStatus}
140
+ activeCount={activeCount}
141
+ totalCost={0}
142
+ />
143
+ <HelpOverlay onClose={() => setDialog('none')} />
144
+ </Box>
145
+ );
146
+ }
147
+
148
+ // Wave dialog
149
+ if (dialog === 'wave') {
150
+ return (
151
+ <Box flexDirection="column">
152
+ <StatusBar
153
+ companyName={api.company?.name ?? 'Loading...'}
154
+ waveId={effectiveWaveId}
155
+ waveStatus={derivedWaveStatus}
156
+ activeCount={activeCount}
157
+ totalCost={0}
158
+ />
159
+ <WaveDialog
160
+ onSubmit={handleWaveSubmit}
161
+ onCancel={() => setDialog('none')}
162
+ />
163
+ </Box>
164
+ );
165
+ }
166
+
167
+ return (
168
+ <Box flexDirection="column">
169
+ {/* Status Bar */}
170
+ <StatusBar
171
+ companyName={api.company?.name ?? 'Loading...'}
172
+ waveId={effectiveWaveId}
173
+ waveStatus={derivedWaveStatus}
174
+ activeCount={activeCount}
175
+ totalCost={0}
176
+ />
177
+
178
+ {/* Separator */}
179
+ <Box width="100%">
180
+ <Text color="gray">{'\u2500'.repeat(70)}</Text>
181
+ </Box>
182
+
183
+ {/* Main content: left (org + sessions) | right (stream) */}
184
+ <Box flexGrow={1}>
185
+ {/* Left column */}
186
+ <Box flexDirection="column" width={28}>
187
+ <OrgTree
188
+ tree={orgTree}
189
+ focused={activePanel === 'org'}
190
+ selectedIndex={selectedRoleIndex}
191
+ flatRoles={flatRoleIds}
192
+ />
193
+ <Box marginTop={1}>
194
+ <SessionList
195
+ sessions={api.sessions}
196
+ focused={activePanel === 'sessions'}
197
+ selectedIndex={selectedSessionIndex}
198
+ />
199
+ </Box>
200
+ </Box>
201
+
202
+ {/* Vertical separator */}
203
+ <Box flexDirection="column" marginX={0}>
204
+ <Text color="gray">{'\u2502\n'.repeat(15)}</Text>
205
+ </Box>
206
+
207
+ {/* Right column: Stream */}
208
+ <StreamPanel
209
+ events={sse.events}
210
+ allRoleIds={flatRoleIds}
211
+ focused={activePanel === 'stream'}
212
+ streamStatus={sse.streamStatus}
213
+ waveId={effectiveWaveId}
214
+ />
215
+ </Box>
216
+
217
+ {/* Separator */}
218
+ <Box width="100%">
219
+ <Text color="gray">{'\u2500'.repeat(70)}</Text>
220
+ </Box>
221
+
222
+ {/* Command Input */}
223
+ <CommandInput
224
+ focused={activePanel === 'command'}
225
+ waveStatus={derivedWaveStatus}
226
+ dialog={dialog}
227
+ />
228
+ </Box>
229
+ );
230
+ };
@@ -0,0 +1,32 @@
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>&gt; </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
+ };
@@ -0,0 +1,51 @@
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
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OrgTree — left panel showing organization hierarchy with real-time status
3
+ */
4
+
5
+ import React from 'react';
6
+ import { Box, Text } from 'ink';
7
+ import type { OrgNode } from '../store';
8
+ import { statusIcon } from '../theme';
9
+
10
+ interface OrgTreeProps {
11
+ tree: OrgNode[];
12
+ focused: boolean;
13
+ selectedIndex: number;
14
+ flatRoles: string[];
15
+ }
16
+
17
+ function statusColor(status: string): string {
18
+ switch (status) {
19
+ case 'working':
20
+ case 'streaming':
21
+ return 'green';
22
+ case 'done':
23
+ return 'gray';
24
+ case 'error':
25
+ return 'red';
26
+ case 'awaiting_input':
27
+ return 'yellow';
28
+ default:
29
+ return 'gray';
30
+ }
31
+ }
32
+
33
+ interface FlatEntry {
34
+ roleId: string;
35
+ name: string;
36
+ level: string;
37
+ status: string;
38
+ prefix: string;
39
+ }
40
+
41
+ function flattenTree(nodes: OrgNode[], prefix: string = '', isLast: boolean[] = []): FlatEntry[] {
42
+ const result: FlatEntry[] = [];
43
+
44
+ for (let i = 0; i < nodes.length; i++) {
45
+ const node = nodes[i];
46
+ const last = i === nodes.length - 1;
47
+
48
+ let linePrefix = '';
49
+ for (let j = 0; j < isLast.length; j++) {
50
+ linePrefix += isLast[j] ? ' ' : '\u2502 ';
51
+ }
52
+ linePrefix += isLast.length > 0 || i > 0 || nodes.length > 1
53
+ ? (last ? '\u2514\u2500 ' : '\u251C\u2500 ')
54
+ : '';
55
+
56
+ result.push({
57
+ roleId: node.role.id,
58
+ name: node.role.name || node.role.id,
59
+ level: node.role.level,
60
+ status: node.status,
61
+ prefix: linePrefix,
62
+ });
63
+
64
+ if (node.children.length > 0) {
65
+ result.push(...flattenTree(node.children, '', [...isLast, last]));
66
+ }
67
+ }
68
+
69
+ return result;
70
+ }
71
+
72
+ export const OrgTree: React.FC<OrgTreeProps> = ({ tree, focused, selectedIndex, flatRoles }) => {
73
+ const entries = flattenTree(tree);
74
+
75
+ // Map flatRoles index to entries
76
+ const flatRoleIdToEntryIdx = new Map<number, number>();
77
+ let roleIdx = 0;
78
+ for (let i = 0; i < entries.length; i++) {
79
+ if (roleIdx < flatRoles.length && flatRoles[roleIdx] === entries[i].roleId) {
80
+ flatRoleIdToEntryIdx.set(roleIdx, i);
81
+ roleIdx++;
82
+ }
83
+ }
84
+
85
+ return (
86
+ <Box flexDirection="column" paddingX={1}>
87
+ <Text bold color={focused ? 'cyan' : 'gray'}>\u2500\u2500 Org Tree \u2500\u2500</Text>
88
+ <Box marginTop={1}>
89
+ <Text color="yellow" bold>{'\uD83D\uDC51'} CEO</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.level === 'c-level' ? entry.name : entry.name}
112
+ </Text>
113
+ <Text color="gray" dimColor> {entry.roleId}</Text>
114
+ </Box>
115
+ );
116
+ })}
117
+ </Box>
118
+ );
119
+ };