flowmind 1.5.4 → 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.
@@ -3,6 +3,8 @@
3
3
  * Analyzes application logs, traces requests, debugs performance issues
4
4
  */
5
5
 
6
+ const { parseLogQueryParams } = require('../../core/log-query-parser');
7
+
6
8
  module.exports = {
7
9
  canHandle(input, context) {
8
10
  if (!input) return false;
@@ -11,7 +13,11 @@ module.exports = {
11
13
 
12
14
  async execute(input, context) {
13
15
  const logService = context.componentRegistry?.getAdapter('logService');
14
- const params = parseLogParams(input);
16
+ const params = parseLogQueryParams(input, {
17
+ allowNaturalKeyword: true,
18
+ allowNaturalLevel: true,
19
+ allowQuotedKeyword: true
20
+ });
15
21
 
16
22
  if (!logService && !params.mock) {
17
23
  return {
@@ -30,18 +36,22 @@ module.exports = {
30
36
  : queryInput;
31
37
  const execution = await logService.queryLogs(queryParams);
32
38
  const summary = summarizeLogExecution(execution);
39
+ const noRecordsMessage = summary.empty
40
+ ? buildNoRecordsMessage(params)
41
+ : null;
33
42
 
34
43
  return {
35
44
  type: 'result',
36
45
  skill: 'log-audit',
37
- message: params.traceId
38
- ? `Executed trace query for: ${params.traceId}`
39
- : `Executed log query: ${params.service || 'all services'}, ${params.level || 'all levels'}, ${params.timeRange || 'last 1 hour'}`,
46
+ message: noRecordsMessage || (params.traceId
47
+ ? `已執行 trace 查詢:${params.traceId}`
48
+ : `已執行日誌查詢:${params.service || '全部服務'},${params.level || '全部級別'},${params.timeRange || '最近 1 小時'}`),
40
49
  data: {
41
50
  action: params.traceId ? 'trace' : 'query',
42
51
  provider: logService.providerName,
43
52
  queryParams,
44
- execution: summary
53
+ execution: summary,
54
+ noRecords: summary.empty
45
55
  },
46
56
  input,
47
57
  timestamp: new Date().toISOString()
@@ -49,26 +59,6 @@ module.exports = {
49
59
  }
50
60
  };
51
61
 
52
- function parseLogParams(input) {
53
- const params = {};
54
- const traceMatch = input.match(/(?:trace[_-]?id|调用链)\s*[:=]?\s*(\S+)/i);
55
- if (traceMatch) params.traceId = traceMatch[1];
56
-
57
- const serviceMatch = input.match(/(?:服务|service)\s*[:=]?\s*(\S+)/i);
58
- if (serviceMatch) params.service = serviceMatch[1];
59
-
60
- const levelMatch = input.match(/(?:级别|level)\s*[:=]?\s*(ERROR|WARN|INFO|DEBUG)/i);
61
- if (levelMatch) params.level = levelMatch[1].toUpperCase();
62
-
63
- const timeMatch = input.match(/(?:最近|last)\s*(\d+)\s*(分钟|小时|分钟|min|hour)/i);
64
- if (timeMatch) params.timeRange = `last ${timeMatch[1]} ${timeMatch[2]}`;
65
-
66
- const keywordMatch = input.match(/(?:关键词|keyword|搜索|search)\s*[:=]?\s*(.+?)(?:\s*$)/i);
67
- if (keywordMatch) params.keyword = keywordMatch[1].trim();
68
-
69
- return params;
70
- }
71
-
72
62
  function buildSLSQuery(params) {
73
63
  const parts = [];
74
64
  if (params.service) parts.push(`service: ${params.service}`);
@@ -93,12 +83,15 @@ function summarizeLogExecution(execution) {
93
83
  const payload = unwrapPayload(execution);
94
84
  const data = payload && typeof payload === 'object' ? payload : {};
95
85
  const records = extractRecords(data);
86
+ const empty = (extractStatus(data) || 'ok') === 'ok' && records.length === 0;
96
87
 
97
88
  return {
98
89
  status: extractStatus(data) || 'ok',
99
90
  total: extractTotal(data, records),
100
91
  records: records.slice(0, 5).map(summarizeRecord),
101
- requestId: data.requestId || data.traceId || data.id || null
92
+ requestId: data.requestId || data.traceId || data.id || null,
93
+ empty,
94
+ rawSummary: summarizeRawExecution(execution, data)
102
95
  };
103
96
  }
104
97
 
@@ -147,7 +140,9 @@ function extractStatus(data) {
147
140
  }
148
141
 
149
142
  function extractTotal(data, records) {
150
- return data.total || data.count || records.length || null;
143
+ if (typeof data.total === 'number') return data.total;
144
+ if (typeof data.count === 'number') return data.count;
145
+ return records.length;
151
146
  }
152
147
 
153
148
  function summarizeRecord(record) {
@@ -180,3 +175,36 @@ function parseMaybeJson(value) {
180
175
  return { text: value };
181
176
  }
182
177
  }
178
+
179
+ function buildNoRecordsMessage(params) {
180
+ if (params.traceId) {
181
+ return `未找到符合 traceId:${params.traceId} 的日誌。請補充服務、關鍵字或時間範圍。`;
182
+ }
183
+
184
+ if (params.keyword) {
185
+ return `未找到符合關鍵字:${params.keyword} 的日誌。請補充服務或時間範圍。`;
186
+ }
187
+
188
+ return '未找到符合條件的日誌。請補充關鍵字、服務或時間範圍。';
189
+ }
190
+
191
+ function summarizeRawExecution(execution, data) {
192
+ const topLevelKeys = data && typeof data === 'object'
193
+ ? Object.keys(data).slice(0, 12)
194
+ : [];
195
+ const contentText = extractContentText(execution);
196
+
197
+ return {
198
+ topLevelKeys,
199
+ hasContent: Array.isArray(execution?.content) && execution.content.length > 0,
200
+ contentPreview: contentText ? truncateText(contentText, 300) : null
201
+ };
202
+ }
203
+
204
+ function truncateText(value, maxLength) {
205
+ const text = String(value);
206
+ if (text.length <= maxLength) {
207
+ return text;
208
+ }
209
+ return `${text.slice(0, maxLength - 1)}…`;
210
+ }
@@ -3,6 +3,8 @@
3
3
  * Query Alibaba Cloud SLS logs, trace ID chain analysis, performance analysis
4
4
  */
5
5
 
6
+ const { parseLogQueryParams } = require('../../core/log-query-parser');
7
+
6
8
  module.exports = {
7
9
  canHandle(input, context) {
8
10
  if (!input) return false;
@@ -10,7 +12,11 @@ module.exports = {
10
12
  },
11
13
 
12
14
  async execute(input, context) {
13
- const params = parseSLSParams(input);
15
+ const params = parseLogQueryParams(input, {
16
+ allowNaturalKeyword: true,
17
+ allowNaturalLevel: true,
18
+ allowQuotedKeyword: true
19
+ });
14
20
 
15
21
  // Determine default endpoint based on environment
16
22
  const env = params.env || 'test';
@@ -78,35 +84,6 @@ module.exports = {
78
84
  }
79
85
  };
80
86
 
81
- function parseSLSParams(input) {
82
- const params = {};
83
- const traceMatch = input.match(/trace[_-]?id\s*[:=]?\s*(\S+)/i);
84
- if (traceMatch) params.traceId = traceMatch[1];
85
-
86
- const serviceMatch = input.match(/(?:服务|service)\s*[:=]?\s*(\S+)/i);
87
- if (serviceMatch) params.service = serviceMatch[1];
88
-
89
- const levelMatch = input.match(/(?:级别|level)\s*[:=]?\s*(ERROR|WARN|INFO|DEBUG)/i);
90
- if (levelMatch) params.level = levelMatch[1].toUpperCase();
91
-
92
- const projectMatch = input.match(/(?:项目|project)\s*[:=]?\s*(\S+)/i);
93
- if (projectMatch) params.project = projectMatch[1];
94
-
95
- const logstoreMatch = input.match(/(?:logstore|日志库)\s*[:=]?\s*(\S+)/i);
96
- if (logstoreMatch) params.logstore = logstoreMatch[1];
97
-
98
- const envMatch = input.match(/(?:环境|env)\s*[:=]?\s*(test|uat|gray|prod)/i);
99
- if (envMatch) params.env = envMatch[1].toLowerCase();
100
-
101
- const timeMatch = input.match(/(?:最近|last)\s*(\d+)\s*(分钟|小时|min|hour)/i);
102
- if (timeMatch) params.timeRange = `last ${timeMatch[1]} ${timeMatch[2]}`;
103
-
104
- const keywordMatch = input.match(/(?:关键词|keyword|搜索)\s*[:=]?\s*(.+?)(?:\s*$)/i);
105
- if (keywordMatch) params.keyword = keywordMatch[1].trim();
106
-
107
- return params;
108
- }
109
-
110
87
  function buildSLSQuery(params) {
111
88
  const parts = [];
112
89
  if (params.service) parts.push(`service: ${params.service}`);
@@ -102,9 +102,9 @@ module.exports = {
102
102
 
103
103
  function parseYuqueParams(input) {
104
104
  const params = {};
105
- if (/搜索|search|查找/i.test(input)) params.action = 'search';
106
- if (/列表|list|仓库|repo/i.test(input)) params.action = 'list';
107
105
  if (/同步|sync|上传|push/i.test(input)) params.action = 'sync';
106
+ else if (/搜索|search|查找/i.test(input)) params.action = 'search';
107
+ else if (/列表|list|仓库|repo/i.test(input)) params.action = 'list';
108
108
 
109
109
  const pathMatch = input.match(/(?:路径|path|文件|file)\s*[:=]?\s*(\S+)/i);
110
110
  if (pathMatch) params.path = pathMatch[1];
package/tui/app.jsx CHANGED
@@ -1,15 +1,51 @@
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);
15
51
  const commandIdRef = React.useRef(0);
@@ -20,6 +56,23 @@ function App({ flowmind, asciiMode = false }) {
20
56
  return () => { mountedRef.current = false; };
21
57
  }, []);
22
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
+
23
76
  const appendMessage = React.useCallback((role, text, metadata = {}) => {
24
77
  const id = ++messageIdRef.current;
25
78
  setMessages(prev => [...prev, { id, role, text, metadata }].slice(-MAX_MESSAGES));
@@ -81,20 +134,35 @@ function App({ flowmind, asciiMode = false }) {
81
134
  }
82
135
  }, [appendMessage]);
83
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
+
84
149
  return (
85
- React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
150
+ React.createElement(TuiErrorBoundary, null,
151
+ React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
86
152
  React.createElement(Box, { flexDirection: 'row', flexGrow: 1 },
87
- 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 }),
88
154
  React.createElement(ChatPanel, {
89
155
  messages,
90
156
  onSubmit: handleCommand,
91
157
  isProcessing: isProcessing,
92
158
  onExit: exit,
93
159
  focused: focusPanel === 'chat',
94
- asciiMode: asciiMode
160
+ asciiMode: asciiMode,
161
+ width: layout.chatWidth
95
162
  })
96
163
  ),
97
- React.createElement(StatusBar, { flowmind: flowmind, focusPanel: focusPanel, asciiMode: asciiMode })
164
+ React.createElement(StatusBar, { flowmind: flowmind, focusPanel: focusPanel, asciiMode: asciiMode, width: layout.columns })
165
+ )
98
166
  )
99
167
  );
100
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) => {
@@ -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
+ };