tycono 0.3.14-beta.1 → 0.3.14-beta.10

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.3.14-beta.1",
3
+ "version": "0.3.14-beta.10",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@ import { questsRouter } from './routes/quests.js';
33
33
  import { coinsRouter } from './routes/coins.js';
34
34
  import { activeSessionsRouter } from './routes/active-sessions.js';
35
35
  import { supervisionRouter } from './routes/supervision.js';
36
+ import { presetsRouter } from './routes/presets.js';
36
37
  import { importKnowledge } from './services/knowledge-importer.js';
37
38
  import { AnthropicProvider, type LLMProvider } from './engine/llm-adapter.js';
38
39
  import { readConfig } from './services/company-config.js';
@@ -212,6 +213,7 @@ export function createExpressApp(): express.Application {
212
213
  app.use('/api/coins', coinsRouter);
213
214
  app.use('/api/active-sessions', activeSessionsRouter);
214
215
  app.use('/api/supervision', supervisionRouter);
216
+ app.use('/api/presets', presetsRouter);
215
217
 
216
218
  app.get('/api/health', (_req, res) => {
217
219
  res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
@@ -1,16 +1,21 @@
1
1
  /**
2
2
  * presets.ts — Preset API routes
3
3
  *
4
- * GET /api/presets — list all preset summaries
5
- * GET /api/presets/:id — get full preset detail
4
+ * GET /api/presets — list all preset summaries
5
+ * GET /api/presets/:id — get full preset detail
6
+ * POST /api/presets/install — install preset from data
7
+ * DELETE /api/presets/:id — remove installed preset
6
8
  */
7
9
  import { Router } from 'express';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import YAML from 'yaml';
8
13
  import { COMPANY_ROOT } from '../services/file-reader.js';
9
- import { getPresetSummaries, getPresetById } from '../services/preset-loader.js';
14
+ import { getPresetSummaries, getPresetById, loadPresets } from '../services/preset-loader.js';
10
15
 
11
16
  export const presetsRouter = Router();
12
17
 
13
- /** GET /api/presets — list preset summaries for TUI */
18
+ /** GET /api/presets — list preset summaries */
14
19
  presetsRouter.get('/', (_req, res) => {
15
20
  try {
16
21
  const summaries = getPresetSummaries(COMPANY_ROOT);
@@ -33,3 +38,86 @@ presetsRouter.get('/:id', (req, res) => {
33
38
  res.status(500).json({ error: 'Failed to load preset' });
34
39
  }
35
40
  });
41
+
42
+ /** POST /api/presets/install — install a preset from provided data */
43
+ presetsRouter.post('/install', (req, res) => {
44
+ try {
45
+ const { id, preset } = req.body as { id: string; preset: Record<string, unknown> };
46
+ if (!id || !preset) {
47
+ res.status(400).json({ error: 'id and preset are required' });
48
+ return;
49
+ }
50
+
51
+ // Validate preset has required fields
52
+ if (!preset.name || !preset.roles || !Array.isArray(preset.roles)) {
53
+ res.status(400).json({ error: 'preset must have name and roles array' });
54
+ return;
55
+ }
56
+
57
+ // Check for conflict with existing preset
58
+ const existing = getPresetById(COMPANY_ROOT, id);
59
+ if (existing && !existing.isDefault) {
60
+ res.status(409).json({ error: `Preset already installed: ${id}` });
61
+ return;
62
+ }
63
+
64
+ // Create preset directory and write preset.yaml
65
+ const presetDir = path.join(COMPANY_ROOT, 'company', 'presets', id);
66
+ fs.mkdirSync(presetDir, { recursive: true });
67
+
68
+ // Write preset.yaml
69
+ const yamlContent = YAML.stringify(preset);
70
+ fs.writeFileSync(path.join(presetDir, 'preset.yaml'), yamlContent);
71
+
72
+ // Create subdirectories for roles/knowledge/skills
73
+ fs.mkdirSync(path.join(presetDir, 'roles'), { recursive: true });
74
+ fs.mkdirSync(path.join(presetDir, 'knowledge'), { recursive: true });
75
+ fs.mkdirSync(path.join(presetDir, 'skills'), { recursive: true });
76
+
77
+ // Write knowledge docs if provided
78
+ const knowledge = req.body.knowledge as Array<{ filename: string; content: string }> | undefined;
79
+ if (knowledge) {
80
+ for (const doc of knowledge) {
81
+ fs.writeFileSync(path.join(presetDir, 'knowledge', doc.filename), doc.content);
82
+ }
83
+ }
84
+
85
+ // Write role yamls if provided
86
+ const roleDefinitions = req.body.roleDefinitions as Array<{ id: string; yaml: string }> | undefined;
87
+ if (roleDefinitions) {
88
+ for (const role of roleDefinitions) {
89
+ const roleDir = path.join(presetDir, 'roles', role.id);
90
+ fs.mkdirSync(roleDir, { recursive: true });
91
+ fs.writeFileSync(path.join(roleDir, 'role.yaml'), role.yaml);
92
+ }
93
+ }
94
+
95
+ res.json({ ok: true, id, path: `company/presets/${id}` });
96
+ } catch (err) {
97
+ res.status(500).json({ error: `Install failed: ${err instanceof Error ? err.message : 'unknown'}` });
98
+ }
99
+ });
100
+
101
+ /** DELETE /api/presets/:id — remove installed preset */
102
+ presetsRouter.delete('/:id', (req, res) => {
103
+ try {
104
+ const { id } = req.params;
105
+ if (id === 'default' || id === '_default') {
106
+ res.status(400).json({ error: 'Cannot remove default preset' });
107
+ return;
108
+ }
109
+
110
+ const presetDir = path.join(COMPANY_ROOT, 'company', 'presets', id);
111
+ if (!fs.existsSync(presetDir)) {
112
+ res.status(404).json({ error: `Preset not found: ${id}` });
113
+ return;
114
+ }
115
+
116
+ // Remove preset directory recursively
117
+ fs.rmSync(presetDir, { recursive: true, force: true });
118
+
119
+ res.json({ ok: true, id });
120
+ } catch (err) {
121
+ res.status(500).json({ error: `Remove failed: ${err instanceof Error ? err.message : 'unknown'}` });
122
+ }
123
+ });
@@ -180,7 +180,7 @@ class ExecutionManager {
180
180
 
181
181
  this.executions.set(execId, execution);
182
182
 
183
- this.initializeAndRunExecution(execution, params, orgTree);
183
+ this.initializeAndRunExecution(execution, params, orgTree, presetId);
184
184
 
185
185
  return execution;
186
186
  }
@@ -189,6 +189,7 @@ class ExecutionManager {
189
189
  execution: Execution,
190
190
  params: StartExecutionParams,
191
191
  orgTree: ReturnType<typeof buildOrgTree>,
192
+ presetId?: string,
192
193
  ): Promise<void> {
193
194
  try {
194
195
  const ports = await portRegistry.allocate(execution.sessionId || execution.id, params.roleId, params.task);
@@ -724,8 +725,8 @@ Your job: monitor progress, course-correct if needed, wait for completion, then
724
725
  console.log(`[ExecMgr] Supervision recovery: ${deadExecution.roleId} died with ${runningChildren.length} running children. Restarting.`);
725
726
 
726
727
  // Propagate waveId from the dead session
727
- const deadSession = getSession(deadExecution.sessionId);
728
- const waveId = deadSession?.waveId;
728
+ const deadSes = getSession(deadExecution.sessionId);
729
+ const waveId = deadSes?.waveId;
729
730
 
730
731
  // Create new session for recovery
731
732
  const newSession = createSession(deadExecution.roleId, {
@@ -172,13 +172,15 @@ class SupervisorHeartbeat {
172
172
  const waveSessions = listSessions().filter(s => s.waveId === waveId);
173
173
  const ceoSession = waveSessions.find(s => s.roleId === 'ceo') ?? null;
174
174
 
175
- // Read original directive from wave artifact file
175
+ // Read original directive + preset from wave artifact file
176
176
  let originalDirective = '';
177
+ let originalPreset: string | undefined;
177
178
  try {
178
179
  const waveFile = path.join(COMPANY_ROOT, 'operations', 'waves', `${waveId}.json`);
179
180
  if (fs.existsSync(waveFile)) {
180
181
  const waveData = JSON.parse(fs.readFileSync(waveFile, 'utf-8'));
181
182
  originalDirective = waveData.directive ?? '';
183
+ originalPreset = waveData.preset;
182
184
  }
183
185
  } catch { /* ignore */ }
184
186
 
@@ -188,6 +190,7 @@ class SupervisorHeartbeat {
188
190
  waveId,
189
191
  directive: originalDirective || text,
190
192
  continuous: false,
193
+ preset: originalPreset,
191
194
  supervisorSessionId: ceoSession?.id ?? null,
192
195
  executionId: null,
193
196
  status: 'stopped',
package/src/tui/app.tsx CHANGED
@@ -14,6 +14,8 @@
14
14
  import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
15
15
  import { Box, Text, useApp, useInput } from 'ink';
16
16
  import { StatusBar } from './components/StatusBar';
17
+ import { OrgTree } from './components/OrgTree';
18
+ import { StreamView } from './components/StreamView';
17
19
  import { CommandMode, type StreamLine } from './components/CommandMode';
18
20
  import { PanelMode } from './components/PanelMode';
19
21
  import { SetupWizard } from './components/SetupWizard';
@@ -668,8 +670,51 @@ export const App: React.FC = () => {
668
670
  // Command Mode: scrollable terminal (no fullscreen)
669
671
  // Panel Mode: fullscreen (intentional — like vim for inspection)
670
672
  if (mode === 'panel') {
673
+ // OOM debug levels: 0=full, 1=minimal, 2=orgTree only, 3=stream only
674
+ const debugLevel = parseInt(process.env.PANEL_MINIMAL || '0', 10);
675
+ if (debugLevel === 1) {
676
+ return (
677
+ <Box flexDirection="column">
678
+ <Text color="cyan">Panel Mode (minimal)</Text>
679
+ <Text color="gray">Events: {sse.events.length} | Press Esc</Text>
680
+ </Box>
681
+ );
682
+ }
683
+ if (debugLevel === 2) {
684
+ return (
685
+ <Box flexDirection="column">
686
+ <OrgTree tree={orgTree} focused={true} selectedIndex={0} flatRoles={flatRoleIds} ceoStatus="idle" />
687
+ <Text color="gray">OrgTree only | Press Esc</Text>
688
+ </Box>
689
+ );
690
+ }
691
+ if (debugLevel === 3) {
692
+ return (
693
+ <Box flexDirection="column">
694
+ <StreamView events={sse.events} allRoleIds={flatRoleIds} streamStatus={sse.streamStatus} waveId={focusedWaveId} roleLabel="All" />
695
+ <Text color="gray">StreamView only | Press Esc</Text>
696
+ </Box>
697
+ );
698
+ }
699
+ if (debugLevel === 4) {
700
+ // Full layout structure but empty content
701
+ return (
702
+ <Box flexDirection="column" height={termHeight}>
703
+ <Box flexGrow={1}>
704
+ <Box flexDirection="column" width={28}>
705
+ <Text color="green">Left Panel</Text>
706
+ </Box>
707
+ <Text color="gray">{'\u2502'}</Text>
708
+ <Box flexGrow={1} flexDirection="column" overflow="hidden">
709
+ <Text color="cyan">Right Panel</Text>
710
+ </Box>
711
+ </Box>
712
+ <StatusBar companyName="test" waveIndex={1} waveCount={1} waveStatus="idle" activeCount={0} portCount={0} totalCost={0} />
713
+ </Box>
714
+ );
715
+ }
671
716
  return (
672
- <Box flexDirection="column" height={termHeight}>
717
+ <Box flexDirection="column">
673
718
  <Box flexGrow={1} flexDirection="column">
674
719
  <PanelMode
675
720
  tree={orgTree}
@@ -1,6 +1,6 @@
1
1
  /**
2
- * OrgTree — left panel showing organization hierarchy with real-time status
3
- * CEO is now selectable (index 0 in flatRoles)
2
+ * OrgTree — left panel showing organization hierarchy
3
+ * Simplified to single Text render to prevent yoga OOM on wide terminals
4
4
  */
5
5
 
6
6
  import React from 'react';
@@ -16,103 +16,36 @@ interface OrgTreeProps {
16
16
  ceoStatus?: string;
17
17
  }
18
18
 
19
- function statusColor(status: string): string {
20
- switch (status) {
21
- case 'working':
22
- case 'streaming':
23
- return 'green';
24
- case 'done':
25
- return 'gray';
26
- case 'error':
27
- return 'red';
28
- case 'awaiting_input':
29
- return 'yellow';
30
- default:
31
- return 'gray';
32
- }
33
- }
34
-
35
- interface FlatEntry {
36
- roleId: string;
37
- level: string;
38
- status: string;
39
- prefix: string;
40
- }
41
-
42
- function flattenTree(nodes: OrgNode[], prefix: string = '', isLast: boolean[] = []): FlatEntry[] {
43
- const result: FlatEntry[] = [];
44
-
19
+ function flattenTree(nodes: OrgNode[], isLast: boolean[] = []): Array<{ roleId: string; status: string; line: string }> {
20
+ const result: Array<{ roleId: string; status: string; line: string }> = [];
45
21
  for (let i = 0; i < nodes.length; i++) {
46
22
  const node = nodes[i];
47
23
  const last = i === nodes.length - 1;
48
-
49
- let linePrefix = '';
24
+ let prefix = '';
50
25
  for (let j = 0; j < isLast.length; j++) {
51
- linePrefix += isLast[j] ? ' ' : '\u2502 ';
26
+ prefix += isLast[j] ? ' ' : '\u2502 ';
52
27
  }
53
- linePrefix += isLast.length > 0 || i > 0 || nodes.length > 1
54
- ? (last ? '\u2514\u2500 ' : '\u251C\u2500 ')
55
- : '';
56
-
57
- result.push({
58
- roleId: node.role.id,
59
- level: node.role.level,
60
- status: node.status,
61
- prefix: linePrefix,
62
- });
63
-
28
+ prefix += last ? '\u2514\u2500 ' : '\u251C\u2500 ';
29
+ const icon = statusIcon(node.status);
30
+ result.push({ roleId: node.role.id, status: node.status, line: `${prefix}${icon} ${node.role.id}` });
64
31
  if (node.children.length > 0) {
65
- result.push(...flattenTree(node.children, '', [...isLast, last]));
32
+ result.push(...flattenTree(node.children, [...isLast, last]));
66
33
  }
67
34
  }
68
-
69
35
  return result;
70
36
  }
71
37
 
72
38
  export const OrgTree: React.FC<OrgTreeProps> = React.memo(({ tree, focused, selectedIndex, flatRoles, ceoStatus }) => {
73
- const entries = flattenTree(tree);
74
- const isCeoSelected = focused && flatRoles[selectedIndex] === 'ceo';
75
39
  const ceoIcon = statusIcon(ceoStatus ?? 'idle');
76
- const ceoColor = statusColor(ceoStatus ?? 'idle');
40
+ const entries = flattenTree(tree);
41
+
42
+ // Render entire tree as single Text block (1 yoga node instead of 50+)
43
+ const lines = [`${ceoIcon} CEO`, ...entries.map(e => e.line)];
77
44
 
78
45
  return (
79
46
  <Box flexDirection="column" paddingX={1}>
80
47
  <Text bold color={focused ? 'cyan' : 'gray'}>{'\u2500\u2500 Org Tree \u2500\u2500'}</Text>
81
- <Box marginTop={1}>
82
- <Text color={ceoColor} bold={ceoStatus === 'working'}>{ceoIcon} </Text>
83
- <Text
84
- color={isCeoSelected ? 'cyan' : 'yellow'}
85
- bold={isCeoSelected}
86
- inverse={isCeoSelected}
87
- >
88
- CEO
89
- </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.roleId}
112
- </Text>
113
- </Box>
114
- );
115
- })}
48
+ <Text color="white">{'\n' + lines.join('\n')}</Text>
116
49
  </Box>
117
50
  );
118
51
  });
@@ -401,7 +401,7 @@ const PanelModeInner: React.FC<PanelModeProps> = ({
401
401
  <Text color="gray">{separatorStr}</Text>
402
402
 
403
403
  {/* Right: Tabbed panel */}
404
- <Box flexGrow={1} flexDirection="column" overflow="hidden">
404
+ <Box flexGrow={1} flexDirection="column">
405
405
  {/* Tab bar */}
406
406
  <Box paddingX={1} marginBottom={0}>
407
407
  {(['stream', 'docs', 'info'] as RightTab[]).map(tab => (
@@ -1,13 +1,13 @@
1
1
  /**
2
- * StreamView — detailed stream panel for Panel Mode (right side)
3
- * Shows full event details with timestamps for a selected role.
4
- * No aggressive truncation shows tools, thinking, dispatch like Claude Code.
2
+ * StreamView — stream panel for Panel Mode
3
+ * Simplified to single Text render to prevent yoga OOM on wide terminals.
4
+ * Previous: 30 events × 3 React elements = 90 yoga nodes → OOM on 245+ columns
5
+ * Now: 1 Text element with pre-formatted string
5
6
  */
6
7
 
7
8
  import React from 'react';
8
9
  import { Box, Text } from 'ink';
9
10
  import type { SSEEvent } from '../api';
10
- import { getRoleColor } from '../theme';
11
11
 
12
12
  interface StreamViewProps {
13
13
  events: SSEEvent[];
@@ -21,111 +21,58 @@ function formatTime(ts: string): string {
21
21
  try {
22
22
  const d = new Date(ts);
23
23
  return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
24
- } catch {
25
- return '--:--:--';
26
- }
24
+ } catch { return '--:--:--'; }
27
25
  }
28
26
 
29
- function renderEvent(event: SSEEvent): { content: string; contentColor: string } | null {
30
- switch (event.type) {
31
- case 'msg:start':
32
- return {
33
- content: `\u25B6 Started: ${(event.data.task as string)?.replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80) ?? ''}`,
34
- contentColor: 'green',
35
- };
27
+ function eventToLine(event: SSEEvent): string | null {
28
+ const time = formatTime(event.ts);
29
+ const role = event.roleId.padEnd(12);
36
30
 
31
+ switch (event.type) {
32
+ case 'msg:start': {
33
+ const task = ((event.data.task as string) ?? '').replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80);
34
+ return `${time} ${role} \u25B6 Started: ${task}`;
35
+ }
37
36
  case 'msg:done': {
38
37
  const turns = event.data.turns as number | undefined;
39
- return {
40
- content: `\u2713 Done${turns ? ` (${turns} turns)` : ''}`,
41
- contentColor: 'green',
42
- };
38
+ return `${time} ${role} \u2713 Done${turns ? ` (${turns} turns)` : ''}`;
43
39
  }
44
-
45
40
  case 'msg:error':
46
- return {
47
- content: `\u2717 Error: ${(event.data.error as string ?? event.data.message as string ?? '').slice(0, 120)}`,
48
- contentColor: 'red',
49
- };
50
-
41
+ return `${time} ${role} \u2717 ${((event.data.error ?? event.data.message) as string ?? '').slice(0, 80)}`;
51
42
  case 'text': {
52
- const text = ((event.data.text as string) ?? '');
53
- if (!text.trim()) return null;
54
- // Don't truncate let terminal wrap
55
- return { content: text, contentColor: 'white' };
43
+ const text = ((event.data.text as string) ?? '').trim();
44
+ if (!text) return null;
45
+ return `${time} ${role} ${text.slice(0, 120)}`;
56
46
  }
57
-
58
47
  case 'thinking': {
59
- const text = ((event.data.text as string) ?? '').slice(0, 150);
60
- if (!text.trim()) return null;
61
- return { content: `\uD83D\uDCAD ${text}`, contentColor: 'gray' };
48
+ const text = ((event.data.text as string) ?? '').trim().slice(0, 100);
49
+ if (!text) return null;
50
+ return `${time} ${role} \uD83D\uDCAD ${text}`;
62
51
  }
63
-
64
52
  case 'tool:start': {
65
53
  const name = (event.data.name as string) ?? 'tool';
66
- const input = event.data.input;
54
+ const input = event.data.input as Record<string, unknown> | undefined;
67
55
  let detail = '';
68
- if (input && typeof input === 'object') {
69
- const inp = input as Record<string, unknown>;
70
- if (inp.file_path) detail = ` ${String(inp.file_path)}`;
71
- else if (inp.command) detail = ` ${String(inp.command).slice(0, 80)}`;
72
- else if (inp.pattern) detail = ` ${String(inp.pattern)}`;
73
- else detail = ` ${JSON.stringify(input).slice(0, 80)}`;
56
+ if (input) {
57
+ if (input.file_path) detail = ` ${String(input.file_path).slice(0, 60)}`;
58
+ else if (input.command) detail = ` ${String(input.command).slice(0, 60)}`;
59
+ else if (input.pattern) detail = ` ${String(input.pattern)}`;
74
60
  }
75
- return {
76
- content: `\u2192 ${name}${detail}`,
77
- contentColor: 'gray',
78
- };
61
+ return `${time} ${role} \u2192 ${name}${detail}`;
79
62
  }
80
-
81
- case 'tool:result':
82
- return {
83
- content: `\u2190 ${(event.data.name as string) ?? 'tool'} done`,
84
- contentColor: 'gray',
85
- };
86
-
87
63
  case 'dispatch:start':
88
- return {
89
- content: `\u21D2 dispatch ${event.data.targetRole as string ?? ''}: ${(event.data.task as string)?.replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 80) ?? ''}`,
90
- contentColor: 'yellow',
91
- };
92
-
93
- case 'dispatch:done':
94
- return {
95
- content: `\u21D0 ${event.data.targetRole as string ?? ''} completed`,
96
- contentColor: 'yellow',
97
- };
98
-
99
- case 'msg:awaiting_input': {
100
- const question = (event.data.question as string) ?? '';
101
- return {
102
- content: question ? `? ${question.slice(0, 120)}` : '? Awaiting input...',
103
- contentColor: 'yellow',
104
- };
105
- }
106
-
107
- // Hidden (truly internal only)
108
- case 'heartbeat:tick':
109
- case 'heartbeat:skip':
110
- case 'prompt:assembled':
111
- case 'trace:response':
112
- return null;
113
-
64
+ return `${time} ${role} \u21D2 dispatch ${event.data.targetRole as string ?? ''}`;
114
65
  default:
115
66
  return null;
116
67
  }
117
68
  }
118
69
 
119
70
  const StreamViewInner: React.FC<StreamViewProps> = ({
120
- events,
121
- allRoleIds,
122
- streamStatus,
123
- waveId,
124
- roleLabel,
71
+ events, allRoleIds, streamStatus, waveId, roleLabel,
125
72
  }) => {
126
- const maxVisible = 30;
73
+ const termRows = process.stdout.rows || 40;
74
+ const maxVisible = Math.min(Math.max(5, termRows - 15), 20);
127
75
  const visibleEvents = events.slice(-maxVisible);
128
-
129
76
  const turnCount = events.filter(e => e.type === 'text' || e.type === 'tool:start').length;
130
77
 
131
78
  const statusLabel = streamStatus === 'streaming' ? '\u25CF streaming'
@@ -133,39 +80,24 @@ const StreamViewInner: React.FC<StreamViewProps> = ({
133
80
  : streamStatus === 'error' ? '\u2717 error'
134
81
  : 'idle';
135
82
 
83
+ // Build single text block (1 yoga node instead of 90+)
84
+ const lines = visibleEvents
85
+ .map(e => eventToLine(e))
86
+ .filter(Boolean) as string[];
87
+
88
+ const content = lines.length > 0
89
+ ? lines.join('\n')
90
+ : (waveId ? `Streaming... waiting for ${roleLabel !== 'All' ? roleLabel + ' ' : ''}events` : 'No active stream. Dispatch a wave to start.');
91
+
136
92
  return (
137
- <Box flexDirection="column" paddingX={1} flexGrow={1}>
138
- <Box justifyContent="space-between">
139
- <Text bold color="cyan">
140
- Stream ({roleLabel})
141
- </Text>
93
+ <Box flexDirection="column" paddingX={1}>
94
+ <Text bold color="cyan">
95
+ Stream ({roleLabel}){' '}
142
96
  <Text color={streamStatus === 'streaming' ? 'green' : 'gray'}>
143
97
  {statusLabel} {turnCount > 0 ? `turn ${turnCount}` : ''}
144
98
  </Text>
145
- </Box>
146
-
147
- {visibleEvents.length === 0 && (
148
- <Box marginTop={1}>
149
- <Text color="gray" dimColor>
150
- {waveId
151
- ? `Streaming... waiting for ${roleLabel !== 'All' ? roleLabel + ' ' : ''}events`
152
- : 'No active stream. Dispatch a wave to start.'}
153
- </Text>
154
- </Box>
155
- )}
156
-
157
- {visibleEvents.map((event, i) => {
158
- const rendered = renderEvent(event);
159
- if (!rendered) return null;
160
- const roleColor = getRoleColor(event.roleId, allRoleIds);
161
- return (
162
- <Box key={`${event.seq}-${i}`}>
163
- <Text color="gray" dimColor>{formatTime(event.ts)} </Text>
164
- <Text color={roleColor} bold>{event.roleId.padEnd(12)}</Text>
165
- <Text color={rendered.contentColor} wrap="truncate">{rendered.content}</Text>
166
- </Box>
167
- );
168
- })}
99
+ </Text>
100
+ <Text color="white" wrap="truncate">{content}</Text>
169
101
  </Box>
170
102
  );
171
103
  };