@orbit-intelligence/orbit-agent 0.3.13 → 0.3.15

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.
@@ -0,0 +1,237 @@
1
+ import { ANTHROPIC_CATALOG } from '../models.js';
2
+ /**
3
+ * Anthropic native streaming provider (Messages API).
4
+ * HTTP: POST https://api.anthropic.com/v1/messages with `x-api-key` +
5
+ * `anthropic-version` headers, SSE stream.
6
+ *
7
+ * Event mapping:
8
+ * text block → token
9
+ * thinking block → reasoning
10
+ * tool_use block → tool_call_start / tool_call_args / tool_call_end
11
+ */
12
+ const ANTHROPIC_ENDPOINT = 'https://api.anthropic.com/v1/messages';
13
+ const ANTHROPIC_VERSION = '2023-06-01';
14
+ const DEFAULT_MAX_TOKENS = 8192;
15
+ const THINKING_BUDGET = { low: 2048, medium: 8192, high: 32768 };
16
+ const ANTHROPIC_MODELS = ANTHROPIC_CATALOG.map((m) => m.id);
17
+ export function createAnthropicProvider(apiKey) {
18
+ return {
19
+ id: 'anthropic',
20
+ modelCount: ANTHROPIC_MODELS.length,
21
+ listModels() {
22
+ return ANTHROPIC_MODELS.map((m) => `anthropic/${m}`);
23
+ },
24
+ async *stream(llmOpts) {
25
+ let model = llmOpts.model;
26
+ if (model.startsWith('anthropic/'))
27
+ model = model.slice('anthropic/'.length);
28
+ if (!model)
29
+ model = 'claude-sonnet-5';
30
+ const body = {
31
+ model,
32
+ max_tokens: llmOpts.maxTokens ?? DEFAULT_MAX_TOKENS,
33
+ messages: toAnthropicMessages(llmOpts.messages),
34
+ stream: true,
35
+ };
36
+ const sys = llmOpts.messages.find((m) => m.role === 'system');
37
+ if (sys?.content)
38
+ body.system = sys.content;
39
+ if (llmOpts.tools?.length)
40
+ body.tools = llmOpts.tools.map(toAnthropicTool);
41
+ if (llmOpts.temperature != null)
42
+ body.temperature = llmOpts.temperature;
43
+ const thinking = anthropicThinkingConfig(llmOpts.reasoning);
44
+ if (thinking)
45
+ body.thinking = thinking;
46
+ const res = await fetch(ANTHROPIC_ENDPOINT, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'x-api-key': apiKey,
51
+ 'anthropic-version': ANTHROPIC_VERSION,
52
+ },
53
+ body: JSON.stringify(body),
54
+ signal: llmOpts.signal,
55
+ });
56
+ if (!res.ok) {
57
+ const text = await res.text().catch(() => 'unknown');
58
+ throw new Error(`Anthropic HTTP ${res.status}: ${text.slice(0, 300)}`);
59
+ }
60
+ const reader = res.body?.getReader();
61
+ if (!reader)
62
+ throw new Error('No readable body from Anthropic');
63
+ const usage = { input: 0, output: 0 };
64
+ const blocks = new Map();
65
+ let sentDone = false;
66
+ const decoder = new TextDecoder();
67
+ let buffer = '';
68
+ let eventType = '';
69
+ try {
70
+ while (true) {
71
+ const { done, value } = await reader.read();
72
+ if (done)
73
+ break;
74
+ buffer += decoder.decode(value, { stream: true });
75
+ while (true) {
76
+ const nl = buffer.indexOf('\n');
77
+ if (nl === -1)
78
+ break;
79
+ const line = buffer.slice(0, nl);
80
+ buffer = buffer.slice(nl + 1);
81
+ if (line.startsWith('event:')) {
82
+ eventType = line.slice(6).trim();
83
+ continue;
84
+ }
85
+ if (!line.startsWith('data:'))
86
+ continue;
87
+ const data = line.slice(5).trim();
88
+ if (!data)
89
+ continue;
90
+ const parsed = jsonSafe(data);
91
+ if (!parsed)
92
+ continue;
93
+ for (const ev of handleAnthropicEvent(parsed, eventType, blocks, usage)) {
94
+ yield ev;
95
+ if (ev.type === 'done')
96
+ sentDone = true;
97
+ }
98
+ }
99
+ }
100
+ }
101
+ finally {
102
+ reader.releaseLock();
103
+ }
104
+ if (!sentDone)
105
+ yield { type: 'done', usage: { inputTokens: usage.input, outputTokens: usage.output } };
106
+ },
107
+ };
108
+ }
109
+ function toAnthropicMessages(messages) {
110
+ const out = [];
111
+ for (const m of messages) {
112
+ if (m.role === 'system')
113
+ continue;
114
+ if (m.role === 'assistant') {
115
+ const blocks = [];
116
+ if (m.content)
117
+ blocks.push({ type: 'text', text: m.content });
118
+ for (const tc of m.toolCalls ?? []) {
119
+ blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: safeJson(tc.args) });
120
+ }
121
+ out.push({ role: 'assistant', content: blocks });
122
+ continue;
123
+ }
124
+ if (m.toolResults && m.toolResults.length > 0) {
125
+ const blocks = [];
126
+ for (const r of m.toolResults) {
127
+ blocks.push({ type: 'tool_result', tool_use_id: r.toolCallId, content: r.content });
128
+ }
129
+ out.push({ role: 'user', content: blocks });
130
+ continue;
131
+ }
132
+ out.push({ role: 'user', content: m.content });
133
+ }
134
+ return out;
135
+ }
136
+ function toAnthropicTool(t) {
137
+ return {
138
+ name: t.function.name,
139
+ description: t.function.description,
140
+ input_schema: t.function.parameters ?? { type: 'object', properties: {} },
141
+ };
142
+ }
143
+ function anthropicThinkingConfig(reasoning) {
144
+ if (!reasoning)
145
+ return undefined;
146
+ const effort = reasoning.effort ?? 'medium';
147
+ if (reasoning.enabled === false || effort === 'none')
148
+ return { type: 'disabled' };
149
+ return { type: 'enabled', budget_tokens: THINKING_BUDGET[effort] ?? THINKING_BUDGET['medium'] };
150
+ }
151
+ /**
152
+ * Handle a single Anthropic SSE data payload, mutating block/usage state and
153
+ * returning the StreamEvents to surface. Empty for non-generating events.
154
+ */
155
+ function* handleAnthropicEvent(parsed, _eventType, blocks, usage) {
156
+ const evt = parsed.type;
157
+ if (evt === 'message_start') {
158
+ const msg = parsed.message;
159
+ usage.input = msg?.usage?.input_tokens ?? 0;
160
+ usage.output = msg?.usage?.output_tokens ?? 0;
161
+ return;
162
+ }
163
+ if (evt === 'content_block_start') {
164
+ const index = parsed.index ?? 0;
165
+ const block = parsed.content_block;
166
+ const type = block?.type;
167
+ if (type === 'tool_use') {
168
+ const id = block?.id ?? `claude_${Date.now()}_${block?.name ?? 'tool'}`;
169
+ blocks.set(index, { kind: 'tool_use', id, name: block?.name ?? '', args: '' });
170
+ yield { type: 'tool_call_start', id, name: block?.name ?? '' };
171
+ }
172
+ else if (type === 'text' || type === 'thinking') {
173
+ blocks.set(index, { kind: type, id: '', name: '', args: '' });
174
+ }
175
+ return;
176
+ }
177
+ if (evt === 'content_block_delta') {
178
+ const index = parsed.index ?? 0;
179
+ const delta = parsed.delta;
180
+ const dtype = delta?.type;
181
+ if (dtype === 'text_delta' && typeof delta?.text === 'string') {
182
+ yield { type: 'token', text: delta.text };
183
+ }
184
+ else if (dtype === 'thinking_delta' && typeof delta?.thinking === 'string') {
185
+ yield { type: 'reasoning', text: delta.thinking };
186
+ }
187
+ else if (dtype === 'input_json_delta' && typeof delta?.partial_json === 'string') {
188
+ const block = blocks.get(index);
189
+ if (block && block.kind === 'tool_use') {
190
+ block.args += delta.partial_json;
191
+ if (block.id)
192
+ yield { type: 'tool_call_args', id: block.id, args: delta.partial_json };
193
+ }
194
+ }
195
+ return;
196
+ }
197
+ if (evt === 'content_block_stop') {
198
+ const index = parsed.index ?? 0;
199
+ const block = blocks.get(index);
200
+ if (block && block.kind === 'tool_use') {
201
+ yield { type: 'tool_call_end', id: block.id, name: block.name, args: block.args };
202
+ }
203
+ blocks.delete(index);
204
+ return;
205
+ }
206
+ if (evt === 'message_delta') {
207
+ const mdUsage = parsed.usage;
208
+ if (mdUsage?.output_tokens != null)
209
+ usage.output = mdUsage.output_tokens;
210
+ return;
211
+ }
212
+ if (evt === 'message_stop') {
213
+ yield { type: 'done', usage: { inputTokens: usage.input, outputTokens: usage.output } };
214
+ return;
215
+ }
216
+ if (evt === 'error') {
217
+ const err = parsed.error;
218
+ throw new Error(`Anthropic: ${err?.message ?? JSON.stringify(parsed.error) ?? 'stream error'}`);
219
+ }
220
+ }
221
+ function safeJson(s) {
222
+ try {
223
+ const v = JSON.parse(s);
224
+ return v && typeof v === 'object' ? v : {};
225
+ }
226
+ catch {
227
+ return {};
228
+ }
229
+ }
230
+ function jsonSafe(s) {
231
+ try {
232
+ return JSON.parse(s);
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
@@ -0,0 +1,86 @@
1
+ import { streamOpenAi } from '../http.js';
2
+ import { OLLAMA_CATALOG } from '../models.js';
3
+ /**
4
+ * Ollama (local, OpenAI-compatible) provider.
5
+ *
6
+ * No API key: Ollama accepts both a dummy key and an empty Authorization.
7
+ * Default base http://127.0.0.1:11434 (Android/Termux), overridable via the
8
+ * OLLAMA_HOST env var. Installed models are discovered by probing
9
+ * GET /api/tags (async, short-cached, fast-fail when the server is down).
10
+ */
11
+ export const OLLAMA_DEFAULT_BASE = 'http://127.0.0.1:11434';
12
+ const TAGS_CACHE_MS = 5000;
13
+ const TAGS_TIMEOUT_MS = 4000;
14
+ let tagsCache = null;
15
+ export function ollamaBaseUrl(explicit) {
16
+ return (explicit?.trim() || process.env.OLLAMA_HOST?.trim() || OLLAMA_DEFAULT_BASE).replace(/\/+$/, '');
17
+ }
18
+ async function probeTags(baseUrl) {
19
+ const now = Date.now();
20
+ if (tagsCache && now - tagsCache.at < TAGS_CACHE_MS)
21
+ return tagsCache.names;
22
+ try {
23
+ const ctrl = new AbortController();
24
+ const timer = setTimeout(() => ctrl.abort(new Error('ollama probe timed out')), TAGS_TIMEOUT_MS);
25
+ const res = await fetch(`${baseUrl}/api/tags`, { signal: ctrl.signal });
26
+ clearTimeout(timer);
27
+ if (!res.ok)
28
+ throw new Error(`HTTP ${res.status}`);
29
+ const json = (await res.json());
30
+ const names = dedupeStable((json.models ?? []).map((m) => m.name).filter(Boolean));
31
+ tagsCache = { at: Date.now(), names };
32
+ return names;
33
+ }
34
+ catch {
35
+ tagsCache = { at: Date.now(), names: [] };
36
+ return [];
37
+ }
38
+ }
39
+ /** Collapse `foo:latest` → `foo` while preserving distinct non-latest tags. */
40
+ function dedupeStable(list) {
41
+ const seen = new Set();
42
+ const out = [];
43
+ for (const n of list) {
44
+ const clean = n.endsWith(':latest') ? n.slice(0, n.length - 7) : n;
45
+ if (!seen.has(clean)) {
46
+ seen.add(clean);
47
+ out.push(clean);
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ export function createOllamaProvider(opts = {}) {
53
+ const baseUrl = ollamaBaseUrl(opts.baseUrl);
54
+ const catalog = opts.models && opts.models.length > 0 ? opts.models : OLLAMA_CATALOG.map((m) => m.id);
55
+ return {
56
+ id: 'ollama',
57
+ modelCount: catalog.length,
58
+ async listModels() {
59
+ const names = await probeTags(baseUrl);
60
+ // Empty when the server is unreachable so routing/pickers don't list
61
+ // phantom models; the static catalog is only a wizard fallback.
62
+ return names.map((n) => `ollama/${n}`);
63
+ },
64
+ async *stream(llmOpts) {
65
+ let model = llmOpts.model;
66
+ if (model.startsWith('ollama/'))
67
+ model = model.slice('ollama/'.length);
68
+ if (!model)
69
+ model = catalog[0] ?? 'llama3.2';
70
+ yield* streamOpenAi({
71
+ baseUrl: `${baseUrl}/v1`,
72
+ apiKey: '',
73
+ model,
74
+ messages: llmOpts.messages,
75
+ tools: llmOpts.tools,
76
+ temperature: llmOpts.temperature,
77
+ maxTokens: llmOpts.maxTokens,
78
+ signal: llmOpts.signal,
79
+ reasoning: llmOpts.reasoning,
80
+ // Modern Ollama accepts `thinking` + `reasoning_effort` in the OpenAI
81
+ // endpoint; unknown extra keys are ignored by older builds.
82
+ reasoningStyle: 'deepseek',
83
+ });
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Streamed-content sanitizer.
3
+ *
4
+ * Some gateway models (gpt-oss family, Qwen cloud variants) leak control
5
+ * tokens into the visible content stream: `<|start|>functions.list_dir
6
+ * to=assistant<|message|>{...}` pensieve blocks, dangling `<|...|>` tags,
7
+ * and boundary boilerplate such as a lone `response` line or "Proceed to
8
+ * final answer." Strip those before the TUI renders or history commits them
9
+ * so the user sees the prose, not the plumbing.
10
+ */
11
+ /** Control-rune tag, e.g. `<|start|>`, `<|message|>`, `<|end|>`. */
12
+ const TAG_RE = /<\|[^|>]*\|>/g;
13
+ /**
14
+ * Qwen/OSS pensieve header: `<|start|>functions.<name> to=assistant
15
+ * <|message|>` followed by a mangled JSON echo that model emits verbatim.
16
+ * Removes the header plus the leading portion of the echoed line up to the
17
+ * first blank line, which is where the real prose begins in practice.
18
+ */
19
+ const PENSIVE_RE = /<\|start\|>\n?[\s\S]*?<\|message\|>[ \t]*[^\r\n]*\n/g;
20
+ /** Lone boundary markers some cloud models insert before their answer. */
21
+ const BOUNDARY_LINE_RE = /^[ \t]*(?:response|Proceed to final answer\.?)[ \t]*$/gm;
22
+ /** Collapse 3+ consecutive blank lines down to two (post-strip tidy-up). */
23
+ const BLANK_RUN_RE = /\n{3,}/g;
24
+ /**
25
+ * Sanitize a single streamed token. idempotent; cheap enough to run per
26
+ * token. A `<|` opened mid-chunk but closed later is finished by
27
+ * `sanitizeContent` over the assembled text, so partial matches here are
28
+ * fine.
29
+ */
30
+ export function sanitizeToken(text) {
31
+ let s = text.replace(PENSIVE_RE, '').replace(TAG_RE, '');
32
+ if (s.includes('response') || s.includes('Proceed to final answer')) {
33
+ s = s.replace(BOUNDARY_LINE_RE, '');
34
+ }
35
+ return s;
36
+ }
37
+ /** Sanitize a fully-assembled message/reasoning blob. */
38
+ export function sanitizeContent(text) {
39
+ let s = text
40
+ .replace(PENSIVE_RE, '')
41
+ .replace(TAG_RE, '')
42
+ .replace(BOUNDARY_LINE_RE, '')
43
+ .replace(BLANK_RUN_RE, '\n\n')
44
+ .replace(/^\n+/, '')
45
+ .replace(/\n+$/, '');
46
+ return s;
47
+ }
@@ -12,6 +12,10 @@ const ENV_LABELS = {
12
12
  groq: ['GROQ_API_KEY'],
13
13
  gemini: ['GEMINI_API_KEY'],
14
14
  openrouter: ['OPENROUTER_API_KEY'],
15
+ openai: ['OPENAI_API_KEY'],
16
+ anthropic: ['ANTHROPIC_API_KEY'],
17
+ grok: ['XAI_API_KEY', 'GROK_API_KEY'],
18
+ deepseek: ['DEEPSEEK_API_KEY'],
15
19
  };
16
20
  // The setup wizard calls providers three times in quick succession; don't
17
21
  // re-read + re-parse + re-chmod the keys file on every lookup.
@@ -65,7 +69,13 @@ function fromEnv(provider) {
65
69
  return keys;
66
70
  }
67
71
  export function isImportableDirectly(p) {
68
- return p === 'groq' || p === 'gemini' || p === 'openrouter' || p === 'openai';
72
+ return (p === 'groq' ||
73
+ p === 'gemini' ||
74
+ p === 'openrouter' ||
75
+ p === 'openai' ||
76
+ p === 'anthropic' ||
77
+ p === 'grok' ||
78
+ p === 'deepseek');
69
79
  }
70
80
  export function getProviderSecrets(provider) {
71
81
  const envKeys = fromEnv(provider);
@@ -73,12 +83,13 @@ export function getProviderSecrets(provider) {
73
83
  return { provider, keys: envKeys, source: 'env' };
74
84
  const file = readKeysFile();
75
85
  const fileKeys = file[provider] ?? [];
76
- if (fileKeys.length > 0)
77
- return { provider, keys: fileKeys, source: 'file' };
86
+ const flat = fileKeys.map((k) => (typeof k === 'string' ? k : k.key)).filter(Boolean);
87
+ if (flat.length > 0)
88
+ return { provider, keys: flat, source: 'file' };
78
89
  return null;
79
90
  }
80
91
  export function hasAnySecrets() {
81
- for (const p of ['orbitx', 'groq', 'gemini', 'openrouter']) {
92
+ for (const p of ['orbitx', 'groq', 'gemini', 'openrouter', 'openai', 'anthropic', 'grok', 'deepseek']) {
82
93
  if (getProviderSecrets(p))
83
94
  return true;
84
95
  }
@@ -91,19 +102,37 @@ export function maskSecret(key) {
91
102
  }
92
103
  export function describeProvidersAvailable() {
93
104
  const out = [];
94
- for (const p of ['orbitx', 'groq', 'gemini', 'openrouter']) {
105
+ for (const p of ['orbitx', 'groq', 'gemini', 'openrouter', 'openai', 'anthropic', 'grok', 'deepseek']) {
95
106
  const s = getProviderSecrets(p);
96
107
  if (s && s.keys[0])
97
108
  out.push({ provider: p, masked: maskSecret(s.keys[0]), source: s.source });
98
109
  }
99
110
  return out;
100
111
  }
112
+ export function describeProviderKey(provider) {
113
+ const s = getProviderSecrets(provider);
114
+ if (!s || !s.keys[0])
115
+ return { provider, available: false };
116
+ const named = readKeysFile()[provider]?.find((k) => typeof k === 'object');
117
+ return {
118
+ provider,
119
+ available: true,
120
+ masked: maskSecret(s.keys[0]),
121
+ source: s.source,
122
+ label: named?.name,
123
+ };
124
+ }
101
125
  /**
102
126
  * Persist provider keys to the git-ignored keys.json (0600), merging over any
103
127
  * existing keys so a re-run of the wizard never wipes other providers.
104
128
  */
105
129
  export function writeKeys(provider, keys) {
106
- const trimmed = keys.map((k) => k.trim()).filter(Boolean);
130
+ const trimmed = keys
131
+ .map((k) => {
132
+ const key = typeof k === 'string' ? k.trim() : { name: k.name.trim() || 'default', key: k.key.trim() };
133
+ return typeof key === 'string' ? key : { name: key.name, key: key.key };
134
+ })
135
+ .filter((k) => (typeof k === 'string' ? k : k.key) !== '');
107
136
  if (trimmed.length === 0)
108
137
  return 'no keys provided';
109
138
  const existing = readKeysFile();
@@ -119,3 +148,42 @@ export function writeKeys(provider, keys) {
119
148
  return err.message;
120
149
  }
121
150
  }
151
+ /** Save (or update by label) a single named key for a provider. */
152
+ export function saveNamedKey(provider, name, key) {
153
+ const clean = key.trim();
154
+ if (!clean)
155
+ return 'no key provided';
156
+ const label = name.trim() || 'default';
157
+ const existing = readKeysFile();
158
+ const list = existing[provider] ?? [];
159
+ const idx = list.findIndex((k) => typeof k === 'object' && k.name === label);
160
+ if (idx >= 0)
161
+ list[idx] = { name: label, key: clean };
162
+ else
163
+ list.push({ name: label, key: clean });
164
+ existing[provider] = list;
165
+ try {
166
+ mkdirSync(configDir(), { recursive: true });
167
+ writeFileSync(keysPath(), JSON.stringify(existing, null, 2), 'utf8');
168
+ chmodSync(keysPath(), 0o600);
169
+ invalidateKeysCache();
170
+ return null;
171
+ }
172
+ catch (err) {
173
+ return err.message;
174
+ }
175
+ }
176
+ /** Human "masked · source [label]" description for a provider's configured key. */
177
+ export function describeKey(provider) {
178
+ const envKeys = fromEnv(provider);
179
+ if (envKeys.length > 0 && envKeys[0])
180
+ return { masked: maskSecret(envKeys[0]), source: 'env' };
181
+ const file = readKeysFile();
182
+ const entries = file[provider] ?? [];
183
+ const first = entries[0];
184
+ if (!first)
185
+ return null;
186
+ if (typeof first === 'string')
187
+ return { masked: maskSecret(first), source: 'file' };
188
+ return { masked: maskSecret(first.key), source: 'file', label: first.name };
189
+ }
@@ -252,7 +252,13 @@ function contentLines(state, theme, width) {
252
252
  lines.push('');
253
253
  PROVIDER_SPECS.forEach((p, i) => {
254
254
  const av = describeProvidersAvailable().some((a) => a.provider === p.id);
255
- const flag = p.id === 'orbitx' ? ' free · auto-routes Groq/Gemini/OpenRouter' : av ? ' ✓ available' : ` ${dimSgr}(keys not set)${reset}`;
255
+ const flag = p.id === 'orbitx'
256
+ ? ' free · auto-routes Groq/Gemini/OpenRouter'
257
+ : p.id === 'ollama'
258
+ ? ' local · auto-detect http://127.0.0.1:11434'
259
+ : av
260
+ ? ' ✓ available'
261
+ : ` ${dimSgr}(keys not set)${reset}`;
256
262
  if (i === state.providerSelected)
257
263
  lines.push(`${accentSgr}▸ ${p.label}${reset}${dimSgr}${flag}${reset}`);
258
264
  else
@@ -33,7 +33,11 @@ export function InkApp({ controller }) {
33
33
  const theme = store.theme ?? buildTheme('tokyonight');
34
34
  const menuing = store.input.currentBuffer().startsWith('/');
35
35
  const menuLines = menuing ? store.slashMatches().length + 1 : 0;
36
- const modelLines = store.modelPicker.open ? Math.min(store.models.length, 10) + 1 : 0;
36
+ const modelLines = store.modelPicker.open
37
+ ? store.keyEntry
38
+ ? 8
39
+ : Math.min(store.pickRows.length, 12) + 3
40
+ : 0;
37
41
  const dockLines = store.dockOpen
38
42
  ? store.agents.size === 0
39
43
  ? 2
@@ -63,5 +67,5 @@ export function InkApp({ controller }) {
63
67
  revealChars: store.revealChars,
64
68
  });
65
69
  }
66
- return (_jsx(StoreContext.Provider, { value: store, children: _jsxs(Box, { width: cols, height: bodyRows, flexDirection: "column", overflow: "hidden", children: [_jsx(Header, { width: cols }), _jsx(Text, { dimColor: true, children: 'Tip: /help for commands · /theme to switch' }), _jsx(Transcript, { rows: msgRows, viewport: viewport }), working && _jsx(WorkingStatus, {}), store.pendingAsk && _jsx(PermissionModal, { cols: cols }), store.dockOpen && _jsx(AgentDock, { width: cols, height: dockLines }), menuing && _jsx(SlashMenu, { width: cols }), store.modelPicker.open && _jsx(ModelPicker, { width: cols }), _jsx(Box, { height: 1, children: _jsx(Text, { dimColor: true, wrap: "truncate-end", children: '─'.repeat(Math.max(1, cols)) }) }), _jsx(Composer, {}), _jsx(StatusLine, { width: cols })] }) }));
70
+ return (_jsx(StoreContext.Provider, { value: store, children: _jsxs(Box, { width: cols, height: bodyRows, flexDirection: "column", overflow: "hidden", children: [_jsx(Header, { width: cols }), _jsx(Text, { dimColor: true, children: 'Tip: /help for commands · /theme to switch' }), _jsx(Transcript, { rows: msgRows, viewport: viewport }), working && _jsx(WorkingStatus, {}), store.pendingAsk && _jsx(PermissionModal, { cols: cols }), store.dockOpen && _jsx(AgentDock, { width: cols, height: dockLines }), menuing && _jsx(SlashMenu, { width: cols }), store.modelPicker.open && _jsx(ModelPicker, { height: modelLines }), _jsx(Box, { height: 1, children: _jsx(Text, { dimColor: true, wrap: "truncate-end", children: '─'.repeat(Math.max(1, cols)) }) }), _jsx(Composer, {}), _jsx(StatusLine, { width: cols })] }) }));
67
71
  }