osai-agent 4.0.0

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.
Files changed (86) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +72 -0
  3. package/src/agent/context.js +141 -0
  4. package/src/agent/loop/context-summary.js +196 -0
  5. package/src/agent/loop/directory-utils.js +102 -0
  6. package/src/agent/loop/local.js +196 -0
  7. package/src/agent/loop/loop-detection.js +288 -0
  8. package/src/agent/loop/stream-parser.js +515 -0
  9. package/src/agent/loop/tool-executor.js +470 -0
  10. package/src/agent/loop/verification.js +263 -0
  11. package/src/agent/loop/websocket.js +80 -0
  12. package/src/agent/prompt.js +259 -0
  13. package/src/agent/react-loop.js +697 -0
  14. package/src/agent/subagent.js +263 -0
  15. package/src/commands/config.js +53 -0
  16. package/src/commands/connect.js +190 -0
  17. package/src/commands/devices.js +121 -0
  18. package/src/commands/login.js +77 -0
  19. package/src/commands/logout.js +31 -0
  20. package/src/commands/mcp.js +258 -0
  21. package/src/commands/provider.js +633 -0
  22. package/src/commands/register.js +74 -0
  23. package/src/commands/run.js +150 -0
  24. package/src/commands/search.js +64 -0
  25. package/src/commands/session.js +57 -0
  26. package/src/commands/skills.js +54 -0
  27. package/src/commands/stop-subagent.js +58 -0
  28. package/src/index.js +208 -0
  29. package/src/llm/direct.js +317 -0
  30. package/src/memory/store.js +215 -0
  31. package/src/mock-readline.js +27 -0
  32. package/src/parser/dependencies.js +71 -0
  33. package/src/parser/markdown.js +505 -0
  34. package/src/parser/stream.js +96 -0
  35. package/src/prompts/modes/CODING.js +160 -0
  36. package/src/prompts/modes/GENERAL.js +105 -0
  37. package/src/prompts/modes/NETWORK.js +69 -0
  38. package/src/prompts/modes/SSH.js +53 -0
  39. package/src/prompts/systemPrompt.js +85 -0
  40. package/src/safety/check.js +210 -0
  41. package/src/services/crypto.js +78 -0
  42. package/src/services/executor.js +68 -0
  43. package/src/services/history.js +58 -0
  44. package/src/services/server-url.js +11 -0
  45. package/src/services/session.js +194 -0
  46. package/src/services/ssh.js +176 -0
  47. package/src/services/websocket.js +112 -0
  48. package/src/skills/loader.js +231 -0
  49. package/src/tools/browser.js +434 -0
  50. package/src/tools/local.js +1254 -0
  51. package/src/tools/mcp-client.js +209 -0
  52. package/src/tools/registry.js +132 -0
  53. package/src/tools/search-providers.js +237 -0
  54. package/src/tools/ssh.js +74 -0
  55. package/src/ui/App.js +2031 -0
  56. package/src/ui/animation.js +47 -0
  57. package/src/ui/components/AskUserDialog.js +33 -0
  58. package/src/ui/components/ConfirmationDialog.js +45 -0
  59. package/src/ui/components/DiffView.js +201 -0
  60. package/src/ui/components/Header.js +157 -0
  61. package/src/ui/components/HistoryPicker.js +130 -0
  62. package/src/ui/components/InputShell.js +22 -0
  63. package/src/ui/components/MessageHistory.js +1200 -0
  64. package/src/ui/components/ModalPanel.js +40 -0
  65. package/src/ui/components/ModePicker.js +161 -0
  66. package/src/ui/components/PlanDialog.js +48 -0
  67. package/src/ui/components/ProviderMenu.js +1095 -0
  68. package/src/ui/components/SavePicker.js +106 -0
  69. package/src/ui/components/SelectMenu.js +194 -0
  70. package/src/ui/components/SlashMenu.js +168 -0
  71. package/src/ui/components/SubagentPanel.js +138 -0
  72. package/src/ui/components/TextInputSafe.js +117 -0
  73. package/src/ui/components/TodoPanel.js +54 -0
  74. package/src/ui/components/ToolExecution.js +261 -0
  75. package/src/ui/components/TranscriptViewport.js +99 -0
  76. package/src/ui/diff.js +249 -0
  77. package/src/ui/h.js +7 -0
  78. package/src/ui/mouse-scroll.js +63 -0
  79. package/src/ui/slash-picker.js +58 -0
  80. package/src/ui/terminal.js +41 -0
  81. package/src/ui/theme.js +5 -0
  82. package/src/ui/welcome.js +12 -0
  83. package/src/utils/constants.js +231 -0
  84. package/src/utils/helpers.js +154 -0
  85. package/src/utils/logger.js +81 -0
  86. package/src/utils/sound.js +33 -0
@@ -0,0 +1,117 @@
1
+ // Wrapper around ink-text-input that adds Ctrl+O to the list of keys that
2
+ // should NOT be inserted into the input (so our root useInput in App.js can
3
+ // catch it for the "expand thought" action).
4
+ //
5
+ // This is a thin clone of the upstream source with one extra condition.
6
+ // Keeping a local copy avoids forking the dependency and is the only way to
7
+ // make Ctrl+O work while the user is typing in the input.
8
+
9
+ import React, { useState, useEffect } from 'react';
10
+ import { Text, useInput } from 'ink';
11
+ import chalk from 'chalk';
12
+ import { isOnlySgrMouseInput } from '../mouse-scroll.js';
13
+
14
+ function TextInputSafe({ value: originalValue, placeholder = '', focus = true, mask, highlightPastedText = false, showCursor = true, onChange, onSubmit }) {
15
+ const [state, setState] = useState({
16
+ cursorOffset: (originalValue || '').length,
17
+ cursorWidth: 0,
18
+ });
19
+ const { cursorOffset, cursorWidth } = state;
20
+ useEffect(() => {
21
+ setState(previousState => {
22
+ if (!focus || !showCursor) return previousState;
23
+ const newValue = originalValue || '';
24
+ if (previousState.cursorOffset > newValue.length - 1) {
25
+ return { cursorOffset: newValue.length, cursorWidth: 0 };
26
+ }
27
+ return previousState;
28
+ });
29
+ }, [originalValue, focus, showCursor]);
30
+
31
+ const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
32
+ const value = mask ? mask.repeat(originalValue.length) : originalValue;
33
+ let renderedValue = value;
34
+ let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
35
+ if (showCursor && focus) {
36
+ renderedPlaceholder =
37
+ placeholder.length > 0
38
+ ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
39
+ : chalk.inverse(' ');
40
+ renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
41
+ let i = 0;
42
+ for (const char of value) {
43
+ renderedValue +=
44
+ i >= cursorOffset - cursorActualWidth && i <= cursorOffset
45
+ ? chalk.inverse(char)
46
+ : char;
47
+ i++;
48
+ }
49
+ if (value.length > 0 && cursorOffset === value.length) {
50
+ renderedValue += chalk.inverse(' ');
51
+ }
52
+ }
53
+
54
+ useInput((input, key) => {
55
+ // [FIX-ctrl-o] Ajout de (key.ctrl && input === 'o') à la liste des keys
56
+ // filtrées : Ctrl+O ne doit pas être inséré comme un caractère texte dans
57
+ // l'input. Il remonte au useInput racine qui gère l'action "déplier le
58
+ // thought".
59
+ if (
60
+ key.upArrow ||
61
+ key.downArrow ||
62
+ (key.ctrl && input === 'c') ||
63
+ key.tab ||
64
+ (key.shift && key.tab) ||
65
+ (key.ctrl && input === 'o')
66
+ ) {
67
+ return;
68
+ }
69
+ // Filtre les séquences SGR souris qui fuient depuis parseKeypress
70
+ // (ex: [<64;86;11M ou plusieurs concaténées) — Ink les passe comme texte
71
+ if (isOnlySgrMouseInput(input)) {
72
+ return;
73
+ }
74
+ if (key.return) {
75
+ if (onSubmit) onSubmit(originalValue);
76
+ return;
77
+ }
78
+ let nextCursorOffset = cursorOffset;
79
+ let nextValue = originalValue;
80
+ let nextCursorWidth = 0;
81
+ if (key.leftArrow) {
82
+ if (showCursor) nextCursorOffset--;
83
+ } else if (key.rightArrow) {
84
+ if (showCursor) nextCursorOffset++;
85
+ } else if (key.backspace || key.delete) {
86
+ if (cursorOffset > 0) {
87
+ nextValue =
88
+ originalValue.slice(0, cursorOffset - 1) +
89
+ originalValue.slice(cursorOffset, originalValue.length);
90
+ nextCursorOffset--;
91
+ }
92
+ } else {
93
+ nextValue =
94
+ originalValue.slice(0, cursorOffset) +
95
+ input +
96
+ originalValue.slice(cursorOffset, originalValue.length);
97
+ nextCursorOffset += input.length;
98
+ if (input.length > 1) nextCursorWidth = input.length;
99
+ }
100
+ if (cursorOffset < 0) nextCursorOffset = 0;
101
+ if (cursorOffset > originalValue.length) nextCursorOffset = originalValue.length;
102
+ setState({ cursorOffset: nextCursorOffset, cursorWidth: nextCursorWidth });
103
+ if (nextValue !== originalValue) onChange(nextValue);
104
+ }, { isActive: focus });
105
+
106
+ return React.createElement(Text, null, placeholder
107
+ ? value.length > 0
108
+ ? renderedValue
109
+ : renderedPlaceholder
110
+ : renderedValue);
111
+ }
112
+
113
+ export default TextInputSafe;
114
+ export function UncontrolledTextInputSafe({ initialValue = '', ...props }) {
115
+ const [value, setValue] = useState(initialValue);
116
+ return React.createElement(TextInputSafe, { ...props, value, onChange: setValue });
117
+ }
@@ -0,0 +1,54 @@
1
+ import { Box, Text } from 'ink';
2
+ import { h } from '../h.js';
3
+
4
+ function normalizeTodos(input) {
5
+ if (!input) return [];
6
+ if (Array.isArray(input)) return input;
7
+ if (typeof input === 'string') {
8
+ const trimmed = input.trim();
9
+ if (!trimmed) return [];
10
+ try {
11
+ const parsed = JSON.parse(trimmed);
12
+ return Array.isArray(parsed) ? parsed : [];
13
+ } catch {
14
+ return [{ text: trimmed, status: 'pending', priority: 'MEDIUM' }];
15
+ }
16
+ }
17
+ return [];
18
+ }
19
+
20
+ function statusStyle(status) {
21
+ const s = String(status || '').toLowerCase();
22
+ if (s === 'done' || s === 'completed') return { icon: '[x]', color: '#9ece6a' };
23
+ if (s === 'in_progress' || s === 'doing') return { icon: '[~]', color: '#e0af68' };
24
+ return { icon: '[ ]', color: '#7aa2f7' };
25
+ }
26
+
27
+ function priorityColor(priority) {
28
+ const p = String(priority || '').toUpperCase();
29
+ if (p === 'HIGH') return '#f7768e';
30
+ if (p === 'MEDIUM') return '#e0af68';
31
+ return '#73daca';
32
+ }
33
+
34
+ export function TodoPanel({ todos }) {
35
+ const list = normalizeTodos(todos);
36
+ if (!list.length) return null;
37
+
38
+ return h(Box, { flexDirection: 'column', paddingY: 1, borderStyle: 'single', borderColor: 'blue' },
39
+ h(Text, { color: 'blue', bold: true }, 'Tasks:'),
40
+ h(Box, { flexDirection: 'column', paddingLeft: 2 },
41
+ ...list.map((todo, i) => {
42
+ const label = todo.text || todo.description || `Task ${todo.id ?? i + 1}`;
43
+ const status = statusStyle(todo.status);
44
+ const priority = String(todo.priority || 'MEDIUM').toUpperCase();
45
+ return h(Box, { key: `todo_${todo.id ?? i}`, marginTop: i === 0 ? 0 : 0 },
46
+ h(Text, { color: status.color }, `${status.icon} `),
47
+ h(Text, { color: '#c0caf5' }, label),
48
+ h(Text, { color: '#565f89' }, ' '),
49
+ h(Text, { color: priorityColor(priority) }, `[${priority}]`)
50
+ );
51
+ })
52
+ )
53
+ );
54
+ }
@@ -0,0 +1,261 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { h } from '../h.js';
4
+ import { highlightCode } from '../../parser/markdown.js';
5
+ import { ENABLE_UI_ANIMATIONS, useAnimationFrame } from '../animation.js';
6
+
7
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
8
+
9
+ const TOOL_COLORS = {
10
+ READ_FILE: '#9ece6a',
11
+ WRITE_FILE: '#e0af68',
12
+ EDIT_FILE: '#e0af68',
13
+ APPEND_FILE: '#e0af68',
14
+ DELETE_FILE: '#f7768e',
15
+ FETCH_URL: '#7dcfff',
16
+ WEB_SEARCH: '#bb9af7',
17
+ LOCAL_CMD: '#f7768e',
18
+ SSH_CMD: '#f7768e',
19
+ GREP: '#73daca',
20
+ GLOB: '#73daca',
21
+ SEARCH_FILE: '#73daca',
22
+ LIST_DIR: '#9ece6a',
23
+ TREE_VIEW: '#9ece6a',
24
+ FILE_INFO: '#9ece6a',
25
+ CREATE_DIR: '#e0af68',
26
+ RUN_SCRIPT: '#f7768e',
27
+ MOVE_FILE: '#e0af68',
28
+ COPY_FILE: '#e0af68',
29
+ GIT: '#f7768e',
30
+ BROWSE: '#7dcfff',
31
+ BROWSE_SEARCH: '#bb9af7',
32
+ BROWSE_EXTRACT: '#7dcfff',
33
+ ASK_USER: '#7aa2f7',
34
+ PLAN_MODE: '#7dcfff',
35
+ DIAG_POST_EDIT: '#e0af68',
36
+ TODO_ADD: '#9ece6a',
37
+ TODO_COMPLETE: '#9ece6a',
38
+ TODO_UPDATE: '#9ece6a',
39
+ TODO_LIST: '#9ece6a',
40
+ TODO_CLEAR: '#f7768e',
41
+ SKILL_LIST: '#bb9af7',
42
+ LOAD_SKILL: '#bb9af7',
43
+ CREATE_SKILL: '#bb9af7',
44
+ TASK: '#7dcfff',
45
+ };
46
+
47
+ const DEFAULT_COLOR = '#e0af68';
48
+ const OSC8_START = '\u001B]8;;';
49
+ const OSC8_END = '\u001B]8;;\u0007';
50
+
51
+ function getToolColor(name) {
52
+ return TOOL_COLORS[name] || DEFAULT_COLOR;
53
+ }
54
+
55
+ function truncate(value, max = 60) {
56
+ const s = String(value || '');
57
+ if (s.length <= max) return s;
58
+ return s.slice(0, max - 3) + '...';
59
+ }
60
+
61
+ function supportsHyperlinks() {
62
+ if (process.env.OSAI_DISABLE_OSC8 === '1') return false;
63
+ if (process.env.OSAI_ENABLE_OSC8 === '1') return true;
64
+ const term = (process.env.TERM || '').toLowerCase();
65
+ if (term === 'dumb') return false;
66
+ if (process.platform === 'win32') {
67
+ return Boolean(process.env.WT_SESSION || process.env.TERM_PROGRAM || term.includes('xterm'));
68
+ }
69
+ return Boolean(
70
+ process.env.WT_SESSION ||
71
+ process.env.KITTY_WINDOW_ID ||
72
+ process.env.VTE_VERSION ||
73
+ process.env.TERM_PROGRAM ||
74
+ process.env.KONSOLE_VERSION
75
+ );
76
+ }
77
+
78
+ function normalizeUrl(raw) {
79
+ const input = String(raw || '').trim().replace(/[),.;]+$/, '');
80
+ if (!input) return null;
81
+ const candidate = /^www\./i.test(input) ? `https://${input}` : input;
82
+ try {
83
+ const parsed = new URL(candidate);
84
+ if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) return null;
85
+ return parsed.toString();
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function linkText(label, rawUrl) {
92
+ const safeUrl = normalizeUrl(rawUrl);
93
+ if (!safeUrl) return label;
94
+ if (!supportsHyperlinks()) return `${label} (${safeUrl})`;
95
+ return `${OSC8_START}${safeUrl}\u0007${label}${OSC8_END}`;
96
+ }
97
+
98
+ function formatTarget(tc) {
99
+ if (!tc) return { text: '', url: null };
100
+ if (tc.path) return { text: truncate(tc.path), url: null };
101
+ if (tc.cmd) return { text: truncate(tc.cmd.replace(/\s+/g, ' ').trim()), url: null };
102
+ if (tc.url) {
103
+ const url = normalizeUrl(tc.url);
104
+ return { text: truncate(url || tc.url), url };
105
+ }
106
+ if (tc.query) return { text: truncate(tc.query), url: null };
107
+ if (tc.pattern) return { text: truncate(tc.pattern), url: null };
108
+ if (tc.source) return { text: truncate(tc.source), url: null };
109
+ if (tc.name) return { text: truncate(tc.name), url: null };
110
+ if (tc.prompt) return { text: truncate(tc.prompt), url: null };
111
+ if (tc.description) return { text: truncate(tc.description), url: null };
112
+ return { text: '', url: null };
113
+ }
114
+
115
+ function inferCodeLang(text) {
116
+ const src = String(text || '').trim();
117
+ if (!src) return '';
118
+ if ((src.startsWith('{') && src.endsWith('}')) || (src.startsWith('[') && src.endsWith(']'))) {
119
+ try {
120
+ JSON.parse(src);
121
+ return 'json';
122
+ } catch {}
123
+ }
124
+ if (/^\s*(Select-|Get-|Set-|Where-Object|ForEach-Object)\b/m.test(src)) return 'powershell';
125
+ if (/^\s*(npm|node|git|cd|ls|cat|grep|sed|awk|chmod|curl|wget)\b/m.test(src) || /^\s*[$#]\s+/.test(src)) return 'bash';
126
+ if (/\b(import\s+.+\s+from|const\s+\w+\s*=|let\s+\w+\s*=|function\s+\w+\(|=>)\b/.test(src)) return 'javascript';
127
+ if (/\b(def\s+\w+\(|class\s+\w+|from\s+\w+\s+import|import\s+\w+)\b/.test(src)) return 'python';
128
+ return '';
129
+ }
130
+
131
+ function isLikelyCode(text) {
132
+ const src = String(text || '');
133
+ if (!src.trim()) return false;
134
+ if (src.includes('\n')) return true;
135
+ if (/^\s*[\[{]/.test(src) && /[\]}]\s*$/.test(src)) return true;
136
+ return /[;{}()[\]=<>]/.test(src);
137
+ }
138
+
139
+ function CodeOutput({ text, color = '#c0caf5' }) {
140
+ if (!text) return null;
141
+ const lang = inferCodeLang(text);
142
+ return h(Box, { paddingLeft: 4, flexDirection: 'column', marginTop: 1, borderStyle: 'round', borderColor: '#2a2e3f', paddingX: 1 },
143
+ lang ? h(Text, { color: '#565f89' }, lang) : null,
144
+ h(Text, { ansi: true, color }, highlightCode(text, lang))
145
+ );
146
+ }
147
+
148
+ function SearchResults({ results }) {
149
+ if (!results || !results.length) return null;
150
+ return h(Box, { flexDirection: 'column', paddingLeft: 4, marginTop: 1 },
151
+ ...results.map((item, idx) => {
152
+ const title = item.title || 'Untitled';
153
+ const link = normalizeUrl(item.link || item.url || '');
154
+ const snippet = item.snippet || item.description || '';
155
+ return h(Box, { key: `s_${idx}`, flexDirection: 'column', marginBottom: 1 },
156
+ h(Text, { color: '#c0caf5', bold: true }, `[${idx + 1}] ${title}`),
157
+ link ? h(Text, { ansi: true, color: '#7dcfff', underline: true }, ` ${linkText(link, link)}`) : null,
158
+ snippet ? h(Text, { color: '#9aa5ce' }, ` ${snippet}`) : null
159
+ );
160
+ })
161
+ );
162
+ }
163
+
164
+ export function ToolItem({ tool }) {
165
+ const frame = useAnimationFrame();
166
+
167
+ const color = getToolColor(tool.name);
168
+ const target = formatTarget(tool.toolCall);
169
+ const isSubagent = tool.toolCall?._subagent;
170
+ const prefix = tool.name === 'TASK' ? 'Subagent' : isSubagent ? 'Subagent tool' : null;
171
+
172
+ return h(Box, { paddingLeft: 2, paddingY: 0 },
173
+ h(Text, { color: '#7aa2f7' }, ENABLE_UI_ANIMATIONS ? ` ${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ` : ' … '),
174
+ prefix ? h(Text, { color: '#7dcfff' }, `${prefix} `) : null,
175
+ h(Text, { color, bold: true }, tool.name),
176
+ target.text ? h(Text, { color: '#565f89' }, ': ') : null,
177
+ target.text
178
+ ? target.url
179
+ ? h(Text, { ansi: true, color: '#7dcfff', underline: true }, linkText(target.text, target.url))
180
+ : h(Text, { color: '#565f89' }, target.text)
181
+ : null
182
+ );
183
+ }
184
+
185
+ export function ToolResult({ result }) {
186
+ if (!result) return null;
187
+
188
+ const color = getToolColor(result.name);
189
+ const label = result.success ? '✓' : '✗';
190
+ const labelColor = result.success ? '#9ece6a' : '#f7768e';
191
+ const out = String(result.output || '');
192
+ const target = formatTarget(result.toolCall);
193
+
194
+ let display = out;
195
+ let resultsList = null;
196
+
197
+ if (result.name === 'READ_FILE') {
198
+ const lines = out.split('\n');
199
+ display = `${lines[0] || ''} (${Math.max(0, lines.length - 1)} lines)`;
200
+ } else if (out.startsWith('{') || out.startsWith('[')) {
201
+ try {
202
+ const parsed = JSON.parse(out);
203
+ if (parsed?.results && Array.isArray(parsed.results)) {
204
+ resultsList = parsed.results;
205
+ display = '';
206
+ } else {
207
+ display = JSON.stringify(parsed, null, 2);
208
+ }
209
+ } catch {
210
+ display = out;
211
+ }
212
+ }
213
+
214
+ return h(Box, { flexDirection: 'column', paddingLeft: 2, paddingY: 0 },
215
+ h(Box,
216
+ h(Text, { color: labelColor, bold: true }, ` ${label} `),
217
+ h(Text, { color }, result.name),
218
+ target.text ? h(Text, { color: '#565f89' }, ': ') : null,
219
+ target.text
220
+ ? target.url
221
+ ? h(Text, { ansi: true, color: '#7dcfff', underline: true }, linkText(target.text, target.url))
222
+ : h(Text, { color: '#565f89' }, target.text)
223
+ : null
224
+ ),
225
+ resultsList ? h(SearchResults, { results: resultsList }) : null,
226
+ result.success && display
227
+ ? (() => {
228
+ const shortened = display.length > 500 ? `${display.slice(0, 500)}...` : display;
229
+ return isLikelyCode(shortened)
230
+ ? h(CodeOutput, { text: shortened, color: '#c0caf5' })
231
+ : h(Box, { paddingLeft: 4 }, h(Text, { color: '#c0caf5' }, shortened));
232
+ })()
233
+ : null,
234
+ !result.success && out
235
+ ? (() => {
236
+ const shortened = out.length > 500 ? `${out.slice(0, 500)}...` : out;
237
+ return isLikelyCode(shortened)
238
+ ? h(CodeOutput, { text: shortened, color: '#f7768e' })
239
+ : h(Box, { paddingLeft: 4 }, h(Text, { color: '#f7768e' }, shortened));
240
+ })()
241
+ : null
242
+ );
243
+ }
244
+
245
+ export function ToolExecution({ activeTools, completedTools }) {
246
+ const hasAny = (activeTools && activeTools.length > 0) || (completedTools && completedTools.length > 0);
247
+ if (!hasAny) return null;
248
+
249
+ return h(Box, { flexDirection: 'column', paddingY: 0 },
250
+ completedTools && completedTools.length > 0
251
+ ? h(Box, { flexDirection: 'column', paddingBottom: activeTools && activeTools.length > 0 ? 1 : 0 },
252
+ ...completedTools.map(t => h(ToolResult, { key: `${t.id}_done`, result: t }))
253
+ )
254
+ : null,
255
+ activeTools && activeTools.length > 0
256
+ ? h(Box, { flexDirection: 'column' },
257
+ ...activeTools.map(t => h(ToolItem, { key: t.id, tool: t }))
258
+ )
259
+ : null
260
+ );
261
+ }
@@ -0,0 +1,99 @@
1
+ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
2
+ import { Box, Text, useStdout } from 'ink';
3
+ import { ScrollView } from 'ink-scroll-view';
4
+ import { h } from '../h.js';
5
+ import { ExchangeBlock, EventList, UserMessageBlock } from './MessageHistory.js';
6
+
7
+ const SCROLL_BOTTOM_EPSILON = 1;
8
+
9
+ export const TranscriptViewport = forwardRef(function TranscriptViewport({
10
+ height,
11
+ exchanges = [],
12
+ userMessage,
13
+ displayEvents = [],
14
+ columns,
15
+ expandedOutputIndexes,
16
+ expandedThoughtIds,
17
+ thoughtStreaming = false,
18
+ animate = false,
19
+ showDoneSeparator = false,
20
+ TaskSeparator,
21
+ scrollAwayFromLive = false,
22
+ }, ref) {
23
+ const scrollRef = useRef(null);
24
+ const { stdout } = useStdout();
25
+
26
+ useImperativeHandle(ref, () => ({
27
+ scrollBy(delta) {
28
+ scrollRef.current?.scrollBy(delta);
29
+ },
30
+ scrollToBottom() {
31
+ scrollRef.current?.scrollToBottom();
32
+ },
33
+ scrollToTop() {
34
+ scrollRef.current?.scrollToTop();
35
+ },
36
+ remeasure() {
37
+ scrollRef.current?.remeasure();
38
+ },
39
+ isAtBottom() {
40
+ const sv = scrollRef.current;
41
+ if (!sv) return true;
42
+ const bottom = sv.getBottomOffset();
43
+ if (bottom <= 0) return true;
44
+ return sv.getScrollOffset() >= bottom - SCROLL_BOTTOM_EPSILON;
45
+ },
46
+ getViewportHeight() {
47
+ return scrollRef.current?.getViewportHeight() || 1;
48
+ },
49
+ }), []);
50
+
51
+ useEffect(() => {
52
+ const handleResize = () => scrollRef.current?.remeasure();
53
+ stdout?.on?.('resize', handleResize);
54
+ return () => stdout?.off?.('resize', handleResize);
55
+ }, [stdout]);
56
+
57
+ useEffect(() => {
58
+ scrollRef.current?.remeasure();
59
+ }, [height, exchanges.length, displayEvents.length, userMessage]);
60
+
61
+ if (!height || height < 1) return null;
62
+
63
+ return h(Box, {
64
+ flexDirection: 'column',
65
+ width: '100%',
66
+ height,
67
+ overflow: 'hidden',
68
+ flexShrink: 1,
69
+ minHeight: 0,
70
+ },
71
+ h(ScrollView, { ref: scrollRef, flexGrow: 1, width: '100%' },
72
+ exchanges.map((ex, i) =>
73
+ h(ExchangeBlock, {
74
+ key: `ex_${i}`,
75
+ exchange: ex,
76
+ columns,
77
+ expandedOutputIndexes,
78
+ expandedThoughtIds,
79
+ })
80
+ ),
81
+ userMessage ? h(Box, { key: 'current_exchange', flexDirection: 'column' },
82
+ TaskSeparator ? h(TaskSeparator, { columns }) : null,
83
+ h(UserMessageBlock, { message: userMessage }),
84
+ h(Box, { height: 1 }),
85
+ h(EventList, {
86
+ events: displayEvents,
87
+ expandedOutputIndexes,
88
+ thoughtStreaming,
89
+ animate,
90
+ expandedThoughtIds,
91
+ }),
92
+ showDoneSeparator && TaskSeparator ? h(TaskSeparator, { columns }) : null,
93
+ ) : null,
94
+ scrollAwayFromLive ? h(Box, { key: 'scroll_hint', paddingLeft: 2, paddingY: 1 },
95
+ h(Text, { color: '#565f89', italic: true }, 'Scrolled up — End or scroll down to follow live')
96
+ ) : null
97
+ )
98
+ );
99
+ });