flowmind 1.5.3 → 1.5.6

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/tui/app.jsx CHANGED
@@ -1,23 +1,78 @@
1
1
  const React = require('react');
2
- const { Box, useApp, useInput } = require('ink');
2
+ const { Box, Text, useApp, useInput } = require('ink');
3
3
  const Sidebar = require('./components/Sidebar.jsx');
4
4
  const ChatPanel = require('./components/ChatPanel.jsx');
5
5
  const StatusBar = require('./components/StatusBar.jsx');
6
6
  const { formatResultText } = require('./format-result');
7
+ const { getTuiLayout, MIN_COLUMNS, MIN_ROWS } = require('./layout');
8
+
9
+ class TuiErrorBoundary extends React.Component {
10
+ constructor(props) {
11
+ super(props);
12
+ this.state = { error: null };
13
+ }
14
+
15
+ static getDerivedStateFromError(error) {
16
+ return { error };
17
+ }
18
+
19
+ componentDidCatch(error) {
20
+ if (this.props.onError) {
21
+ this.props.onError(error);
22
+ }
23
+ }
24
+
25
+ render() {
26
+ if (this.state.error) {
27
+ return React.createElement(
28
+ Box,
29
+ { flexDirection: 'column', borderStyle: 'single', borderColor: 'red', paddingX: 1, paddingY: 1 },
30
+ React.createElement(Text, { color: 'red', bold: true }, 'TUI render error'),
31
+ React.createElement(Text, { color: 'gray' }, this.state.error.message || 'Unknown render failure'),
32
+ React.createElement(Text, { color: 'gray' }, 'Resize the terminal or restart FlowMind.')
33
+ );
34
+ }
35
+
36
+ return this.props.children;
37
+ }
38
+ }
7
39
 
8
40
  function App({ flowmind, asciiMode = false }) {
9
41
  const MAX_MESSAGES = 60;
10
42
  const [messages, setMessages] = React.useState([]);
11
43
  const [isProcessing, setIsProcessing] = React.useState(false);
12
44
  const [focusPanel, setFocusPanel] = React.useState('chat'); // 'chat' | 'sidebar'
45
+ const [terminalSize, setTerminalSize] = React.useState(() => ({
46
+ columns: Number(process.stdout?.columns) || 0,
47
+ rows: Number(process.stdout?.rows) || 0
48
+ }));
13
49
  const mountedRef = React.useRef(true);
14
50
  const messageIdRef = React.useRef(0);
51
+ const commandIdRef = React.useRef(0);
52
+ const activeCommandRef = React.useRef(false);
15
53
  const { exit } = useApp();
16
54
 
17
55
  React.useEffect(() => {
18
56
  return () => { mountedRef.current = false; };
19
57
  }, []);
20
58
 
59
+ React.useEffect(() => {
60
+ const updateSize = () => {
61
+ if (!mountedRef.current) return;
62
+ setTerminalSize({
63
+ columns: Number(process.stdout?.columns) || 0,
64
+ rows: Number(process.stdout?.rows) || 0
65
+ });
66
+ };
67
+
68
+ updateSize();
69
+ process.stdout?.on?.('resize', updateSize);
70
+
71
+ return () => {
72
+ process.stdout?.removeListener?.('resize', updateSize);
73
+ };
74
+ }, []);
75
+
21
76
  const appendMessage = React.useCallback((role, text, metadata = {}) => {
22
77
  const id = ++messageIdRef.current;
23
78
  setMessages(prev => [...prev, { id, role, text, metadata }].slice(-MAX_MESSAGES));
@@ -32,13 +87,15 @@ function App({ flowmind, asciiMode = false }) {
32
87
  });
33
88
 
34
89
  const handleCommand = React.useCallback(async (input) => {
35
- if (!mountedRef.current) return;
90
+ if (!mountedRef.current || activeCommandRef.current) return;
91
+ activeCommandRef.current = true;
92
+ const commandId = ++commandIdRef.current;
36
93
  appendMessage('user', input);
37
94
  setIsProcessing(true);
38
95
 
39
96
  try {
40
97
  const result = await flowmind.process(input);
41
- if (!mountedRef.current) return;
98
+ if (!mountedRef.current || commandId !== commandIdRef.current) return;
42
99
 
43
100
  appendMessage('flowmind', formatResultText(result), {
44
101
  type: result.type,
@@ -47,9 +104,14 @@ function App({ flowmind, asciiMode = false }) {
47
104
  scene: result.metadata?.sceneMatch?.scene?.name
48
105
  });
49
106
  } catch (e) {
50
- if (mountedRef.current) appendMessage('flowmind', 'Error: ' + e.message, { type: 'error' });
107
+ if (mountedRef.current && commandId === commandIdRef.current) {
108
+ appendMessage('flowmind', 'Error: ' + e.message, { type: 'error' });
109
+ }
51
110
  } finally {
52
- if (mountedRef.current) setIsProcessing(false);
111
+ if (mountedRef.current && commandId === commandIdRef.current) {
112
+ setIsProcessing(false);
113
+ }
114
+ activeCommandRef.current = false;
53
115
  }
54
116
  }, [appendMessage, flowmind]);
55
117
 
@@ -72,20 +134,35 @@ function App({ flowmind, asciiMode = false }) {
72
134
  }
73
135
  }, [appendMessage]);
74
136
 
137
+ const layout = getTuiLayout(terminalSize.columns, terminalSize.rows);
138
+
139
+ if (layout.tooSmall) {
140
+ return (
141
+ React.createElement(Box, { flexDirection: 'column', borderStyle: 'single', borderColor: 'red', paddingX: 1, paddingY: 1 },
142
+ React.createElement(Text, { bold: true, color: 'red' }, 'Terminal too small'),
143
+ React.createElement(Text, { color: 'gray' }, `Need at least ${MIN_COLUMNS}x${MIN_ROWS}. Current size: ${layout.columns}x${layout.rows}.`),
144
+ React.createElement(Text, { color: 'gray' }, 'Resize the terminal to keep the TUI stable.')
145
+ )
146
+ );
147
+ }
148
+
75
149
  return (
76
- React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
150
+ React.createElement(TuiErrorBoundary, null,
151
+ React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
77
152
  React.createElement(Box, { flexDirection: 'row', flexGrow: 1 },
78
- React.createElement(Sidebar, { flowmind: flowmind, width: 32, onSkillSelect: handleSkillSelect, focused: focusPanel === 'sidebar', asciiMode: asciiMode }),
153
+ React.createElement(Sidebar, { flowmind: flowmind, width: layout.sidebarWidth, onSkillSelect: handleSkillSelect, focused: focusPanel === 'sidebar', asciiMode: asciiMode }),
79
154
  React.createElement(ChatPanel, {
80
155
  messages,
81
156
  onSubmit: handleCommand,
82
157
  isProcessing: isProcessing,
83
158
  onExit: exit,
84
159
  focused: focusPanel === 'chat',
85
- asciiMode: asciiMode
160
+ asciiMode: asciiMode,
161
+ width: layout.chatWidth
86
162
  })
87
163
  ),
88
- React.createElement(StatusBar, { flowmind: flowmind, focusPanel: focusPanel, asciiMode: asciiMode })
164
+ React.createElement(StatusBar, { flowmind: flowmind, focusPanel: focusPanel, asciiMode: asciiMode, width: layout.columns })
165
+ )
89
166
  )
90
167
  );
91
168
  }
@@ -4,11 +4,12 @@ const TextInput = require('ink-text-input').default || require('ink-text-input')
4
4
  const Spinner = require('ink-spinner').default || require('ink-spinner');
5
5
  const { getBorderStyle, isExitCommand } = require('../ui');
6
6
 
7
- function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMode = false }) {
7
+ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMode = false, width = 0 }) {
8
8
  const [input, setInput] = React.useState('');
9
9
  const [cmdHistory, setCmdHistory] = React.useState([]);
10
10
  const [historyIndex, setHistoryIndex] = React.useState(-1);
11
11
  const [savedInput, setSavedInput] = React.useState('');
12
+ const isCompact = width < 60;
12
13
 
13
14
  // Handle Up/Down arrow for command history (only when focused)
14
15
  useInput((ch, key) => {
@@ -36,7 +37,7 @@ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMod
36
37
 
37
38
  const handleSubmit = (value) => {
38
39
  const normalized = value.trim();
39
- if (!normalized) return;
40
+ if (!normalized || isProcessing) return;
40
41
  // Add to command history (deduplicate consecutive)
41
42
  if (cmdHistory.length === 0 || cmdHistory[cmdHistory.length - 1] !== normalized) {
42
43
  setCmdHistory(prev => [...prev, normalized]);
@@ -51,7 +52,7 @@ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMod
51
52
  onSubmit(normalized);
52
53
  };
53
54
 
54
- const displayMessages = messages.slice(-18);
55
+ const displayMessages = messages.slice(-(isCompact ? 8 : 18));
55
56
 
56
57
  const renderMeta = (message) => {
57
58
  const metaParts = [];
@@ -89,17 +90,17 @@ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMod
89
90
  return (
90
91
  React.createElement(Box, { flexDirection: 'column', flexGrow: 1, borderStyle: getBorderStyle(asciiMode), borderColor: focused ? 'green' : 'gray', paddingX: 1 },
91
92
  React.createElement(Text, { bold: true, color: focused ? 'green' : 'gray' }, focused ? 'FlowMind Chat [Focused]' : 'FlowMind Chat'),
92
- React.createElement(Text, { color: 'gray' }, 'Single-pane conversation. Output stays in the same view.'),
93
+ React.createElement(Text, { color: 'gray' }, isCompact ? 'Compact chat mode.' : 'Single-pane conversation. Output stays in the same view.'),
93
94
  React.createElement(Box, { flexDirection: 'column', flexGrow: 1, marginTop: 1, overflow: 'hidden' },
94
95
  displayMessages.length === 0 && React.createElement(Box, { flexDirection: 'column' },
95
96
  React.createElement(Text, { color: 'gray' }, 'Describe what you want FlowMind to do.'),
96
- React.createElement(Text, { color: 'gray' }, 'The dialog history stays in this panel, with the input fixed at the bottom.'),
97
+ !isCompact && React.createElement(Text, { color: 'gray' }, 'The dialog history stays in this panel, with the input fixed at the bottom.'),
97
98
  React.createElement(Text, { color: 'gray' }, 'Type `exit` or `/exit` to quit, or press Ctrl+C.')
98
99
  ),
99
100
  displayMessages.map(renderMessage)
100
101
  ),
101
102
  React.createElement(Box, { flexDirection: 'column', borderStyle: getBorderStyle(asciiMode), borderColor: focused ? 'green' : 'gray', paddingX: 1, marginTop: 1 },
102
- React.createElement(Text, { color: 'gray' }, 'Enter send Up/Down history Tab sidebar exit /exit :q Ctrl+C'),
103
+ React.createElement(Text, { color: 'gray' }, isCompact ? 'Enter send | Tab switch | Ctrl+C' : 'Enter send Up/Down history Tab sidebar exit /exit :q Ctrl+C'),
103
104
  isProcessing
104
105
  ? React.createElement(Text, { color: 'yellow' },
105
106
  React.createElement(Spinner, { type: 'dots' }),
@@ -113,7 +114,9 @@ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMod
113
114
  onSubmit: handleSubmit,
114
115
  focus: focused,
115
116
  showCursor: focused,
116
- placeholder: focused ? 'Ask FlowMind to do something...' : 'Press Tab to focus input'
117
+ placeholder: focused
118
+ ? (isCompact ? 'Ask FlowMind...' : 'Ask FlowMind to do something...')
119
+ : 'Press Tab to focus input'
117
120
  })
118
121
  )
119
122
  )
@@ -2,7 +2,7 @@ const React = require('react');
2
2
  const { Box, Text } = require('ink');
3
3
  const { LEVEL_COLORS, LEVEL_NAMES, LEVEL_STATES, getDragonArt } = require('../ui');
4
4
 
5
- function DragonTotem({ honorData, compact, asciiMode = false }) {
5
+ function DragonTotem({ honorData, compact, asciiMode = false, width = 0 }) {
6
6
  const points = honorData?.points || 0;
7
7
  const level = honorData?.level || 0;
8
8
  const art = getDragonArt(level, { asciiMode, compact });
@@ -12,6 +12,17 @@ function DragonTotem({ honorData, compact, asciiMode = false }) {
12
12
  const nextLevelPoints = [1, 10, 30, 60, 100];
13
13
  const nextPoints = nextLevelPoints[level] || null;
14
14
  const pointsToNext = nextPoints !== null ? nextPoints - points : 0;
15
+ const superCompact = width > 0 && width < 26;
16
+
17
+ if (superCompact) {
18
+ return (
19
+ React.createElement(Box, { flexDirection: 'column', paddingX: 1 },
20
+ React.createElement(Text, { bold: true, color: 'cyan' }, 'Dragon'),
21
+ React.createElement(Text, { color: color }, 'Lv' + level + ' ' + levelName),
22
+ React.createElement(Text, { color: 'gray' }, points + ' pts')
23
+ )
24
+ );
25
+ }
15
26
 
16
27
  return (
17
28
  React.createElement(Box, { flexDirection: 'column', paddingX: 1 },
@@ -7,6 +7,7 @@ function Sidebar({ flowmind, width, onSkillSelect, focused, asciiMode = false })
7
7
  const [selectedIndex, setSelectedIndex] = React.useState(0);
8
8
  const [skills, setSkills] = React.useState([]);
9
9
  const [honorData, setHonorData] = React.useState({ points: 0, level: 0, stats: {} });
10
+ const isCompact = width < 30;
10
11
 
11
12
  React.useEffect(() => {
12
13
  if (flowmind) {
@@ -24,27 +25,38 @@ function Sidebar({ flowmind, width, onSkillSelect, focused, asciiMode = false })
24
25
  }
25
26
  }, [flowmind]);
26
27
 
28
+ React.useEffect(() => {
29
+ if (skills.length === 0) {
30
+ setSelectedIndex(0);
31
+ return;
32
+ }
33
+ if (selectedIndex >= skills.length) {
34
+ setSelectedIndex(skills.length - 1);
35
+ }
36
+ }, [selectedIndex, skills.length]);
37
+
27
38
  useInput((input, key) => {
28
39
  if (!focused) return; // Ignore input when sidebar is not focused
40
+ if (skills.length === 0) return;
29
41
  if (key.upArrow) setSelectedIndex(prev => Math.max(0, prev - 1));
30
42
  else if (key.downArrow) setSelectedIndex(prev => Math.min(skills.length - 1, prev + 1));
31
43
  else if (key.return && skills[selectedIndex] && onSkillSelect) onSkillSelect(skills[selectedIndex]);
32
44
  });
33
45
 
34
- const barWidth = Math.max(10, width - 4);
46
+ const barWidth = Math.max(8, width - 4);
35
47
  const progress = honorData.points > 0 ? Math.min(1, honorData.points / 100) : 0;
36
48
  const progressBar = getProgressBar(barWidth, progress, asciiMode);
37
49
 
38
50
  return (
39
51
  React.createElement(Box, { flexDirection: 'column', width: width, borderStyle: getBorderStyle(asciiMode), borderColor: focused ? 'cyan' : 'gray', paddingX: 1 },
40
52
  React.createElement(Text, { color: focused ? 'cyan' : 'gray' }, focused ? 'Sidebar [Focused]' : 'Sidebar'),
41
- React.createElement(DragonTotem, { honorData: honorData, compact: true, asciiMode: asciiMode }),
53
+ !isCompact && React.createElement(DragonTotem, { honorData: honorData, compact: true, asciiMode: asciiMode, width: width }),
42
54
  React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
43
55
  React.createElement(Text, { bold: true, color: 'cyan' }, 'Progress'),
44
56
  React.createElement(Text, { color: 'green' }, progressBar),
45
57
  React.createElement(Text, { color: 'gray' }, honorData.points + '/100 pts')
46
58
  ),
47
- React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
59
+ !isCompact && React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
48
60
  React.createElement(Text, { bold: true, color: 'cyan' }, 'Skills (' + skills.length + ')'),
49
61
  React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
50
62
  skills.length === 0 && React.createElement(Text, { color: 'gray' }, 'No skills loaded'),
@@ -60,14 +72,14 @@ function Sidebar({ flowmind, width, onSkillSelect, focused, asciiMode = false })
60
72
  )
61
73
  ),
62
74
  React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
63
- React.createElement(Text, { color: 'gray' }, 'Tab: switch focus'),
64
- React.createElement(Text, { color: 'gray' }, 'Enter: inspect in chat')
75
+ React.createElement(Text, { color: 'gray' }, isCompact ? 'Tab: switch focus' : 'Tab: switch focus'),
76
+ !isCompact && React.createElement(Text, { color: 'gray' }, 'Enter: inspect in chat')
65
77
  ),
66
78
  React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
67
79
  React.createElement(Text, { bold: true, color: 'cyan' }, 'Stats'),
68
80
  React.createElement(Text, { color: 'gray' }, 'Skills used: ' + (honorData.stats?.skillUseCount || 0)),
69
- React.createElement(Text, { color: 'gray' }, 'Learnings: ' + (honorData.stats?.learningCount || 0)),
70
- React.createElement(Text, { color: 'gray' }, 'New skills: ' + (honorData.stats?.newSkillCount || 0))
81
+ !isCompact && React.createElement(Text, { color: 'gray' }, 'Learnings: ' + (honorData.stats?.learningCount || 0)),
82
+ !isCompact && React.createElement(Text, { color: 'gray' }, 'New skills: ' + (honorData.stats?.newSkillCount || 0))
71
83
  )
72
84
  )
73
85
  );
@@ -2,10 +2,11 @@ const React = require('react');
2
2
  const { Box, Text } = require('ink');
3
3
  const { LEVEL_NAMES, getBorderStyle, getStatusDot } = require('../ui');
4
4
 
5
- function StatusBar({ flowmind, focusPanel, asciiMode = false }) {
5
+ function StatusBar({ flowmind, focusPanel, asciiMode = false, width = 0 }) {
6
6
  const [aiStatus, setAiStatus] = React.useState(null);
7
7
  const [componentStatus, setComponentStatus] = React.useState(null);
8
8
  const [honorData, setHonorData] = React.useState(null);
9
+ const isCompact = width < 100;
9
10
 
10
11
  React.useEffect(() => {
11
12
  if (flowmind) {
@@ -24,6 +25,32 @@ function StatusBar({ flowmind, focusPanel, asciiMode = false }) {
24
25
  const level = honorData?.level || 0;
25
26
  const points = honorData?.points || 0;
26
27
 
28
+ if (isCompact) {
29
+ return (
30
+ React.createElement(Box, { flexDirection: 'column', borderStyle: getBorderStyle(asciiMode), borderColor: 'gray', paddingX: 1 },
31
+ React.createElement(Text, null,
32
+ React.createElement(Text, { color: 'gray' }, 'AI: '),
33
+ React.createElement(Text, { color: aiOk ? 'green' : 'red' }, aiName),
34
+ React.createElement(Text, { color: aiOk ? 'green' : 'red' }, getStatusDot(aiOk, asciiMode))
35
+ ),
36
+ React.createElement(Text, null,
37
+ React.createElement(Text, { color: 'gray' }, 'Components: '),
38
+ React.createElement(Text, { color: 'white' }, activeCount + '/' + componentCount),
39
+ React.createElement(Text, { color: 'green' }, ' active')
40
+ ),
41
+ React.createElement(Text, null,
42
+ React.createElement(Text, { color: 'gray' }, 'Honor: '),
43
+ React.createElement(Text, { color: 'yellow' }, LEVEL_NAMES[level]),
44
+ React.createElement(Text, { color: 'gray' }, ' (' + points + ' pts)')
45
+ ),
46
+ React.createElement(Text, null,
47
+ React.createElement(Text, { color: 'gray' }, 'Focus: '),
48
+ React.createElement(Text, { color: focusPanel === 'chat' ? 'green' : 'cyan' }, focusPanel || 'chat')
49
+ )
50
+ )
51
+ );
52
+ }
53
+
27
54
  return (
28
55
  React.createElement(Box, { borderStyle: getBorderStyle(asciiMode), borderColor: 'gray', paddingX: 1, justifyContent: 'space-between' },
29
56
  React.createElement(Text, null,
@@ -176,6 +176,10 @@ function formatResultText(result) {
176
176
  return payload === undefined ? '' : formatScalar(payload);
177
177
  }
178
178
 
179
+ if (getSkillName(payload) === 'log-audit') {
180
+ return formatLogAuditResult(payload);
181
+ }
182
+
179
183
  const message = payload.message;
180
184
  const details = pruneDisplayValue(payload.data);
181
185
  const detailLines = renderValueLines(details);
@@ -195,6 +199,62 @@ function formatResultText(result) {
195
199
  return truncateRenderedText(JSON.stringify(payload, null, 2));
196
200
  }
197
201
 
202
+ function getSkillName(payload) {
203
+ return payload?.skill || payload?.data?.skill || null;
204
+ }
205
+
206
+ function formatLogAuditResult(payload) {
207
+ const data = isPlainObject(payload.data) ? payload.data : {};
208
+ const execution = isPlainObject(data.execution) ? data.execution : {};
209
+ const rawSummary = isPlainObject(execution.rawSummary) ? execution.rawSummary : null;
210
+ const executionDisplay = rawSummary
211
+ ? Object.fromEntries(Object.entries(execution).filter(([key]) => key !== 'rawSummary'))
212
+ : execution;
213
+
214
+ const sections = [];
215
+ if (payload.message) {
216
+ sections.push(payload.message);
217
+ }
218
+
219
+ if (data.noRecords === true) {
220
+ sections.push('狀態:未命中日誌');
221
+ }
222
+
223
+ if (isPlainObject(data.queryParams)) {
224
+ const queryLines = renderValueLines(data.queryParams);
225
+ if (queryLines.length > 0) {
226
+ sections.push(['查詢條件', ...queryLines].join('\n'));
227
+ }
228
+ }
229
+
230
+ if (isPlainObject(executionDisplay) && Object.keys(executionDisplay).length > 0) {
231
+ const executionLines = renderValueLines(executionDisplay);
232
+ if (executionLines.length > 0) {
233
+ sections.push(['執行結果', ...executionLines].join('\n'));
234
+ }
235
+ }
236
+
237
+ if (rawSummary) {
238
+ const rawLines = renderValueLines(rawSummary);
239
+ if (rawLines.length > 0) {
240
+ sections.push(['原始返回', ...rawLines].join('\n'));
241
+ }
242
+ }
243
+
244
+ if (sections.length > 0) {
245
+ return sections.join('\n\n');
246
+ }
247
+
248
+ const details = pruneDisplayValue(payload.data);
249
+ const detailLines = renderValueLines(details);
250
+
251
+ if (detailLines.length > 0) {
252
+ return truncateRenderedText(detailLines.join('\n'));
253
+ }
254
+
255
+ return truncateRenderedText(JSON.stringify(payload, null, 2));
256
+ }
257
+
198
258
  module.exports = {
199
259
  formatResultText,
200
260
  pruneDisplayValue,
package/tui/layout.js ADDED
@@ -0,0 +1,60 @@
1
+ const MIN_COLUMNS = 88;
2
+ const MIN_ROWS = 20;
3
+ const DEFAULT_SIDEBAR_WIDTH = 32;
4
+ const MIN_SIDEBAR_WIDTH = 24;
5
+ const MAX_SIDEBAR_WIDTH = 40;
6
+ const MIN_CHAT_WIDTH = 28;
7
+
8
+ function normalizeDimension(value) {
9
+ const numeric = Number(value);
10
+ if (!Number.isFinite(numeric) || numeric < 0) {
11
+ return 0;
12
+ }
13
+ return Math.floor(numeric);
14
+ }
15
+
16
+ function getTuiLayout(columns, rows) {
17
+ const safeColumns = normalizeDimension(columns);
18
+ const safeRows = normalizeDimension(rows);
19
+ const tooSmall = safeColumns < MIN_COLUMNS || safeRows < MIN_ROWS;
20
+
21
+ if (tooSmall) {
22
+ return {
23
+ columns: safeColumns,
24
+ rows: safeRows,
25
+ tooSmall: true,
26
+ sidebarWidth: Math.min(DEFAULT_SIDEBAR_WIDTH, safeColumns),
27
+ chatWidth: Math.max(0, safeColumns - DEFAULT_SIDEBAR_WIDTH - 2)
28
+ };
29
+ }
30
+
31
+ const sidebarWidth = clamp(
32
+ Math.round(safeColumns * 0.28),
33
+ MIN_SIDEBAR_WIDTH,
34
+ Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, safeColumns - MIN_CHAT_WIDTH - 2))
35
+ );
36
+ const chatWidth = Math.max(MIN_CHAT_WIDTH, safeColumns - sidebarWidth - 2);
37
+
38
+ return {
39
+ columns: safeColumns,
40
+ rows: safeRows,
41
+ tooSmall: false,
42
+ sidebarWidth,
43
+ chatWidth
44
+ };
45
+ }
46
+
47
+ function clamp(value, min, max) {
48
+ return Math.max(min, Math.min(max, value));
49
+ }
50
+
51
+ module.exports = {
52
+ DEFAULT_SIDEBAR_WIDTH,
53
+ MAX_SIDEBAR_WIDTH,
54
+ MIN_CHAT_WIDTH,
55
+ MIN_COLUMNS,
56
+ MIN_ROWS,
57
+ MIN_SIDEBAR_WIDTH,
58
+ getTuiLayout,
59
+ normalizeDimension
60
+ };