thebird 1.2.90 → 1.2.92

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/CHANGELOG.md CHANGED
@@ -1,4 +1,12 @@
1
1
 
2
+ ## [unreleased] 2026-04-21 chat observability — rich ACP/kilo/opencode event stream
3
+ - feat: kilo-http-stream emits status, model-info, reasoning-delta, tool-event, file-event, step-start/finish, file-mirrored, unknown-part
4
+ - feat: PART_HANDLERS dispatch table replaces part-type branching (kilo + opencode unified)
5
+ - feat: agent-chat forwards full event stream via onEvent callback; window.__debug.agent.events rolling log (300 cap)
6
+ - feat: agent stats strip in chat UI — provider·model·duration·txt·rsn·tool·file·step counters, live 4Hz
7
+ - feat: inline event badges in stream ([i] status/model/unknown, [t] tool, [f] file, [s] step)
8
+ - refactor: extracted PROVIDERS + fetchModels + renderEvent + formatStats to docs/chat-providers.js (app.js 229→166)
9
+
2
10
  ## [unreleased] 2026-04-21 node parity pass 12 — internal listen infrastructure
3
11
  - feat: busnet — in-browser TCP-like listen/connect via BroadcastChannel cross-tab fabric
4
12
  - feat: net.createServer now uses busnet — apps listen on ports other in-browser apps can connect to
package/CLAUDE.md CHANGED
@@ -171,6 +171,24 @@ Run examples against real Gemini API to validate message translation.
171
171
  - `stripUnsupported(params, caps)` — removes unsupported features, returns warnings
172
172
  - Defaults: streaming, toolUse, vision, systemMessage = true; jsonMode = false
173
173
 
174
+ ## Chat Observability (docs/)
175
+
176
+ `docs/kilo-http-stream.js` emits rich events via `PART_HANDLERS` dispatch table (kilo HTTP + opencode SSE unified):
177
+ - `status` — lifecycle (connecting, session id, POST status, mirror step)
178
+ - `model-info` — { providerID, modelID } actually routed to (may differ from requested when gm agent plugin hijacks)
179
+ - `text-delta` / `reasoning-delta` — accumulating growth
180
+ - `tool-event` — { toolName, status, input, output, error, id } from `part.type === 'tool'` state
181
+ - `file-event` / `file-mirrored` — agent file writes / sandbox mirror results
182
+ - `step-start` / `step-finish` — { id, tokens?, cost? } boundaries
183
+ - `unknown-part` — diagnostic for unhandled part.type
184
+
185
+ `window.__debug.agent` permanent registry (populated by `agent-chat.js`):
186
+ `{ provider, model, modelActual, providerActual, active, startedAt, finishedAt, durationMs, textChars, reasoningChars, toolCalls, files, steps, lastTool, lastError, events: [...rolling 300] }`
187
+
188
+ Per-provider rolling logs: `window.__debug.kilo.events`, `window.__debug.opencode.events`.
189
+
190
+ UI consumes via 3 channels: `onChunk(delta)` text streaming | `onEvent(ev)` badge rendering via `renderEvent()` dispatch table in `docs/chat-providers.js` | 4Hz poll on `window.__debug.agent` → `#agent-stats` strip (live counters).
191
+
174
192
  ## Files
175
193
 
176
194
  - `lib/convert.js`: Message/tool translation logic
@@ -98,22 +98,39 @@ function buildStream(provider) {
98
98
  return streamOpenAI({ url, apiKey: provider.apiKey, messages: provider.messages, model: provider.model, tools: TOOLS, maxOutputTokens: 8192 });
99
99
  }
100
100
 
101
- export async function agentGenerate(provider, messages, onChunk, onTool) {
102
- Object.assign(window.__debug = window.__debug || {}, {
103
- agent: { provider: provider.type, model: provider.model, active: true, lastTool: null, lastError: null },
104
- });
101
+ const EVENT_LOG_MAX = 300;
102
+
103
+ export async function agentGenerate(provider, messages, onChunk, onTool, onEvent) {
104
+ const agentState = {
105
+ provider: provider.type, model: provider.model, active: true,
106
+ startedAt: Date.now(), lastTool: null, lastError: null,
107
+ events: [], textChars: 0, reasoningChars: 0, toolCalls: 0, files: 0, steps: 0,
108
+ modelActual: null, providerActual: null,
109
+ };
110
+ Object.assign(window.__debug = window.__debug || {}, { agent: agentState });
105
111
  try {
106
112
  for await (const ev of buildStream({ ...provider, messages })) {
107
- if (ev.type === 'text-delta') onChunk(ev.textDelta);
108
- else if (ev.type === 'tool-call') {
109
- window.__debug.agent.lastTool = { name: ev.toolName, args: ev.args };
110
- onTool(ev.toolName, ev.args);
111
- } else if (ev.type === 'error') throw ev.error;
113
+ const rec = { ...ev, t: Date.now() };
114
+ agentState.events.push(rec);
115
+ if (agentState.events.length > EVENT_LOG_MAX) agentState.events.splice(0, agentState.events.length - EVENT_LOG_MAX);
116
+ onEvent?.(ev);
117
+ if (ev.type === 'text-delta') { agentState.textChars += ev.textDelta.length; onChunk(ev.textDelta); }
118
+ else if (ev.type === 'reasoning-delta') { agentState.reasoningChars += ev.textDelta.length; }
119
+ else if (ev.type === 'tool-call' || ev.type === 'tool-event') {
120
+ agentState.toolCalls++;
121
+ agentState.lastTool = { name: ev.toolName, args: ev.args || ev.input, status: ev.status };
122
+ onTool(ev.toolName, ev.args || ev.input || {});
123
+ } else if (ev.type === 'file-event' || ev.type === 'file-mirrored') { agentState.files++; }
124
+ else if (ev.type === 'start-step' || ev.type === 'step-start') { agentState.steps++; }
125
+ else if (ev.type === 'model-info') { agentState.modelActual = ev.modelID; agentState.providerActual = ev.providerID; }
126
+ else if (ev.type === 'error') throw ev.error;
112
127
  }
113
128
  } catch (e) {
114
- window.__debug.agent.lastError = { message: e.message, stack: e.stack, timestamp: Date.now() };
129
+ agentState.lastError = { message: e.message, stack: e.stack, timestamp: Date.now() };
115
130
  throw e;
116
131
  } finally {
117
- window.__debug.agent.active = false;
132
+ agentState.active = false;
133
+ agentState.finishedAt = Date.now();
134
+ agentState.durationMs = agentState.finishedAt - agentState.startedAt;
118
135
  }
119
136
  }
package/docs/app.js CHANGED
@@ -1,48 +1,9 @@
1
1
  import { createElement, applyDiff, htm } from './vendor/ui-libs.js';
2
2
  import { agentGenerate } from './agent-chat.js';
3
+ import { PROVIDERS, fetchModels, renderEvent, formatStats } from './chat-providers.js';
3
4
 
4
5
  const html = htm.bind(createElement);
5
6
 
6
- const PROVIDERS = {
7
- gemini: { label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', keyPlaceholder: 'GEMINI_API_KEY', models: [] },
8
- openai: { label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', keyPlaceholder: 'OPENAI_API_KEY', models: ['gpt-4.1', 'gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini'] },
9
- xai: { label: 'xAI Grok', baseUrl: 'https://api.x.ai/v1', keyPlaceholder: 'XAI_API_KEY', models: ['grok-3', 'grok-3-mini', 'grok-3-fast'] },
10
- groq: { label: 'Groq', baseUrl: 'https://api.groq.com/openai/v1', keyPlaceholder: 'GROQ_API_KEY', models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'] },
11
- mistral: { label: 'Mistral', baseUrl: 'https://api.mistral.ai/v1', keyPlaceholder: 'MISTRAL_API_KEY', models: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'] },
12
- deepseek: { label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', keyPlaceholder: 'DEEPSEEK_API_KEY', models: ['deepseek-chat', 'deepseek-reasoner'] },
13
- cerebras: { label: 'Cerebras', baseUrl: 'https://api.cerebras.ai/v1', keyPlaceholder: 'CEREBRAS_API_KEY', models: ['gpt-oss-120b', 'llama3.1-8b'] },
14
- kilo: { label: 'Kilo Code', baseUrl: 'http://localhost:4780', keyPlaceholder: '(no key needed)', models: ['x-ai/grok-code-fast-1:optimized:free', 'kilo-auto/free', 'openrouter/free', 'stepfun/step-3.5-flash:free', 'nvidia/nemotron-3-super-120b-a12b:free', 'bytedance-seed/dola-seed-2.0-pro:free'] },
15
- opencode: { label: 'opencode (zen)', baseUrl: 'http://localhost:4790', keyPlaceholder: '(needs opencode auth login)', models: ['minimax-m2.5-free', 'nemotron-3-super-free'] },
16
- custom: { label: 'Custom (OpenAI-compat)', baseUrl: '', keyPlaceholder: 'API_KEY', models: [] },
17
- };
18
-
19
- async function fetchGeminiModels(apiKey) {
20
- const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
21
- if (!res.ok) throw new Error(`Models API ${res.status}`);
22
- const { models = [] } = await res.json();
23
- return models
24
- .filter(m => m.supportedGenerationMethods?.includes('generateContent'))
25
- .map(m => ({ id: m.name.replace('models/', ''), label: m.displayName || m.name }));
26
- }
27
-
28
- async function fetchOpenAIModels(baseUrl, apiKey) {
29
- const url = baseUrl.replace(/\/$/, '') + '/models';
30
- const res = await fetch(url, { headers: { 'Authorization': `Bearer ${apiKey}` } });
31
- if (!res.ok) throw new Error(`Models API ${res.status}`);
32
- const { data = [] } = await res.json();
33
- return data.map(m => ({ id: m.id, label: m.id })).sort((a, b) => a.id.localeCompare(b.id));
34
- }
35
-
36
- async function fetchModels(providerType, baseUrl, apiKey) {
37
- if (providerType === 'gemini') return fetchGeminiModels(apiKey);
38
- const staticModels = PROVIDERS[providerType]?.models || [];
39
- try {
40
- return await fetchOpenAIModels(baseUrl, apiKey);
41
- } catch {
42
- return staticModels.map(id => ({ id, label: id }));
43
- }
44
- }
45
-
46
7
  class BirdChat extends HTMLElement {
47
8
  constructor() {
48
9
  super();
@@ -69,6 +30,13 @@ class BirdChat extends HTMLElement {
69
30
  this.render();
70
31
  Object.assign(window.__debug, { acp: { baseUrl: this.state.baseUrl, provider: this.state.providerType } });
71
32
  if (this.state.apiKey) this.loadModels();
33
+ this.statsTimer = setInterval(() => this.updateStats(), 250);
34
+ }
35
+ disconnectedCallback() { if (this.statsTimer) clearInterval(this.statsTimer); }
36
+ updateStats() {
37
+ const el = this.querySelector('#agent-stats');
38
+ if (!el) return;
39
+ el.textContent = formatStats(window.__debug?.agent);
72
40
  }
73
41
 
74
42
  setState(patch) { Object.assign(this.state, patch); this.render(); }
@@ -133,6 +101,8 @@ class BirdChat extends HTMLElement {
133
101
  <button class="tui-btn" onclick=${() => this.setState({ messages: [], status: '' })}>[clear]</button>
134
102
  </div>
135
103
 
104
+ <div id="agent-stats" class="tui-agent-stats"></div>
105
+
136
106
  <div id="msg-list" class="tui-msglist">
137
107
  ${messages.map((m, i) => html`
138
108
  <div key=${i} class=${'tui-msg ' + m.role}>${typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')}</div>`)}
@@ -178,14 +148,15 @@ class BirdChat extends HTMLElement {
178
148
  wrap.appendChild(cursor);
179
149
  const list = this.querySelector('#msg-list');
180
150
  if (list) list.appendChild(wrap);
151
+ const scroll = () => { const l = this.querySelector('#msg-list'); if (l) l.scrollTop = l.scrollHeight; };
181
152
  await agentGenerate(provider, normalizedMessages,
182
- chunk => { full += chunk; streamEl.textContent = full; const l = this.querySelector('#msg-list'); if (l) l.scrollTop = l.scrollHeight; },
183
- (name, args) => { full += `\n[tool: ${name}(${JSON.stringify(args)})]\n`; streamEl.textContent = full; }
153
+ chunk => { full += chunk; streamEl.textContent = full; scroll(); },
154
+ () => {},
155
+ ev => { const rendered = renderEvent(ev); if (rendered) { full += rendered; streamEl.textContent = full; scroll(); } }
184
156
  );
185
157
  wrap.remove();
186
158
  this.setState({ messages: [...normalizedMessages, { role: 'assistant', content: [{ type: 'text', text: full || '(empty)' }] }], streaming: false, streamingText: '' });
187
- const l2 = this.querySelector('#msg-list');
188
- if (l2) l2.scrollTop = l2.scrollHeight;
159
+ scroll();
189
160
  } catch (err) {
190
161
  this.setState({ streaming: false, streamingText: '', status: 'Error: ' + (err?.message || String(err)) });
191
162
  }
@@ -0,0 +1,65 @@
1
+ export const PROVIDERS = {
2
+ gemini: { label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', keyPlaceholder: 'GEMINI_API_KEY', models: [] },
3
+ openai: { label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', keyPlaceholder: 'OPENAI_API_KEY', models: ['gpt-4.1', 'gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini'] },
4
+ xai: { label: 'xAI Grok', baseUrl: 'https://api.x.ai/v1', keyPlaceholder: 'XAI_API_KEY', models: ['grok-3', 'grok-3-mini', 'grok-3-fast'] },
5
+ groq: { label: 'Groq', baseUrl: 'https://api.groq.com/openai/v1', keyPlaceholder: 'GROQ_API_KEY', models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'] },
6
+ mistral: { label: 'Mistral', baseUrl: 'https://api.mistral.ai/v1', keyPlaceholder: 'MISTRAL_API_KEY', models: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'] },
7
+ deepseek: { label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', keyPlaceholder: 'DEEPSEEK_API_KEY', models: ['deepseek-chat', 'deepseek-reasoner'] },
8
+ cerebras: { label: 'Cerebras', baseUrl: 'https://api.cerebras.ai/v1', keyPlaceholder: 'CEREBRAS_API_KEY', models: ['gpt-oss-120b', 'llama3.1-8b'] },
9
+ kilo: { label: 'Kilo Code', baseUrl: 'http://localhost:4780', keyPlaceholder: '(no key needed)', models: ['x-ai/grok-code-fast-1:optimized:free', 'kilo-auto/free', 'openrouter/free', 'stepfun/step-3.5-flash:free', 'nvidia/nemotron-3-super-120b-a12b:free', 'bytedance-seed/dola-seed-2.0-pro:free'] },
10
+ opencode: { label: 'opencode (zen)', baseUrl: 'http://localhost:4790', keyPlaceholder: '(needs opencode auth login)', models: ['minimax-m2.5-free', 'nemotron-3-super-free'] },
11
+ custom: { label: 'Custom (OpenAI-compat)', baseUrl: '', keyPlaceholder: 'API_KEY', models: [] },
12
+ };
13
+
14
+ async function fetchGeminiModels(apiKey) {
15
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
16
+ if (!res.ok) throw new Error(`Models API ${res.status}`);
17
+ const { models = [] } = await res.json();
18
+ return models.filter(m => m.supportedGenerationMethods?.includes('generateContent'))
19
+ .map(m => ({ id: m.name.replace('models/', ''), label: m.displayName || m.name }));
20
+ }
21
+
22
+ async function fetchOpenAIModels(baseUrl, apiKey) {
23
+ const url = baseUrl.replace(/\/$/, '') + '/models';
24
+ const res = await fetch(url, { headers: { 'Authorization': `Bearer ${apiKey}` } });
25
+ if (!res.ok) throw new Error(`Models API ${res.status}`);
26
+ const { data = [] } = await res.json();
27
+ return data.map(m => ({ id: m.id, label: m.id })).sort((a, b) => a.id.localeCompare(b.id));
28
+ }
29
+
30
+ export async function fetchModels(providerType, baseUrl, apiKey) {
31
+ if (providerType === 'gemini') return fetchGeminiModels(apiKey);
32
+ const staticModels = PROVIDERS[providerType]?.models || [];
33
+ try { return await fetchOpenAIModels(baseUrl, apiKey); }
34
+ catch { return staticModels.map(id => ({ id, label: id })); }
35
+ }
36
+
37
+ const fmtArgs = a => { try { const s = JSON.stringify(a); return s.length > 140 ? s.slice(0, 137) + '...' : s; } catch { return '?'; } };
38
+ const badge = (label, cls) => `\n\n[${cls}] ${label}\n`;
39
+
40
+ const RENDERERS = {
41
+ status: ev => badge('status: ' + ev.message, 'i'),
42
+ 'model-info': ev => badge('model: ' + (ev.providerID || '') + '/' + ev.modelID, 'i'),
43
+ 'tool-event': ev => badge('tool ' + (ev.status || '') + ': ' + ev.toolName + ' ' + fmtArgs(ev.input), 't'),
44
+ 'tool-call': ev => badge('tool: ' + ev.toolName + ' ' + fmtArgs(ev.args), 't'),
45
+ 'file-event': ev => badge('file: ' + (ev.filename || ev.url || '?'), 'f'),
46
+ 'file-mirrored': ev => badge('wrote: ' + ev.path, 'f'),
47
+ 'reasoning-delta': ev => ev.textDelta,
48
+ 'step-start': () => badge('step start', 's'),
49
+ 'step-finish': ev => badge('step finish' + (ev.tokens ? ' tokens=' + JSON.stringify(ev.tokens) : ''), 's'),
50
+ 'unknown-part': ev => badge('?part ' + ev.partType, 'i'),
51
+ };
52
+
53
+ export function renderEvent(ev) { const r = RENDERERS[ev.type]; return r ? r(ev) : ''; }
54
+
55
+ export function formatStats(a) {
56
+ if (!a) return '';
57
+ const dur = a.active ? ((Date.now() - a.startedAt) / 1000).toFixed(1) : ((a.durationMs || 0) / 1000).toFixed(1);
58
+ const parts = [a.active ? '●' : '○', a.provider, a.modelActual || a.model, dur + 's', 'txt=' + a.textChars];
59
+ if (a.reasoningChars) parts.push('rsn=' + a.reasoningChars);
60
+ if (a.toolCalls) parts.push('tool=' + a.toolCalls);
61
+ if (a.files) parts.push('file=' + a.files);
62
+ if (a.steps) parts.push('step=' + a.steps);
63
+ if (a.lastError) parts.push('ERR:' + a.lastError.message.slice(0, 40));
64
+ return parts.join(' · ');
65
+ }
@@ -1,17 +1,53 @@
1
1
  import { mirrorFromSandbox } from './kilo-fs-mirror.js';
2
2
 
3
+ const PART_HANDLERS = {
4
+ text: (part, st, emit) => {
5
+ const prior = st.textByPart.get(part.id) || '';
6
+ const txt = part.text || '';
7
+ if (txt.length > prior.length) {
8
+ emit({ type: 'text-delta', textDelta: txt.slice(prior.length) });
9
+ st.textByPart.set(part.id, txt);
10
+ }
11
+ },
12
+ reasoning: (part, st, emit) => {
13
+ const prior = st.reasonByPart.get(part.id) || '';
14
+ const txt = part.text || '';
15
+ if (txt.length > prior.length) {
16
+ emit({ type: 'reasoning-delta', textDelta: txt.slice(prior.length) });
17
+ st.reasonByPart.set(part.id, txt);
18
+ }
19
+ },
20
+ tool: (part, st, emit) => {
21
+ const key = part.id + ':' + (part.state?.status || '');
22
+ if (st.seenTool.has(key)) return;
23
+ st.seenTool.add(key);
24
+ emit({ type: 'tool-event', toolName: part.tool || part.state?.tool, status: part.state?.status, input: part.state?.input, output: part.state?.output, error: part.state?.error, id: part.id });
25
+ },
26
+ file: (part, st, emit) => {
27
+ if (st.seenFile.has(part.id)) return;
28
+ st.seenFile.add(part.id);
29
+ emit({ type: 'file-event', filename: part.filename || part.path, mime: part.mime, url: part.url, id: part.id });
30
+ },
31
+ 'step-start': (part, st, emit) => { if (!st.seenStep.has(part.id+':start')) { st.seenStep.add(part.id+':start'); emit({ type: 'step-start', id: part.id }); } },
32
+ 'step-finish': (part, st, emit) => { if (!st.seenStep.has(part.id+':finish')) { st.seenStep.add(part.id+':finish'); emit({ type: 'step-finish', id: part.id, tokens: part.tokens, cost: part.cost }); st.stepFinished = true; } },
33
+ };
34
+
35
+ function makeState() { return { textByPart: new Map(), reasonByPart: new Map(), seenTool: new Set(), seenFile: new Set(), seenStep: new Set(), stepFinished: false }; }
36
+
3
37
  export async function* streamKiloHTTP({ url, model, messages, providerType, agent }) {
4
38
  yield { type: 'start-step' };
5
39
  const base = (url || 'http://localhost:4780').replace(/\/$/, '');
6
40
  const fsBase = base.replace(/:\d+$/, ':4781');
7
41
  const isOpencode = providerType === 'opencode';
8
42
  const dbgKey = isOpencode ? 'opencode' : 'kilo';
43
+ yield { type: 'status', message: 'connecting ' + base };
9
44
  let sessRes;
10
45
  try { sessRes = await fetch(base + '/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
11
46
  catch (e) { throw new Error(dbgKey + ' serve not reachable at ' + base + ' — start it with: node start-kilo.js --origin ' + location.origin); }
12
47
  if (!sessRes.ok) throw new Error('/session ' + sessRes.status + ': ' + await sessRes.text());
13
48
  const { id: sessionId } = await sessRes.json();
14
- Object.assign(window.__debug = window.__debug || {}, { [dbgKey]: { sessionId, url: base, fsBase, lastStatus: null } });
49
+ Object.assign(window.__debug = window.__debug || {}, { [dbgKey]: { sessionId, url: base, fsBase, lastStatus: null, events: [] } });
50
+ yield { type: 'status', message: 'session ' + sessionId.slice(0, 8) };
15
51
 
16
52
  const userText = messages.filter(m => m.role === 'user').map(m =>
17
53
  typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')
@@ -23,55 +59,69 @@ export async function* streamKiloHTTP({ url, model, messages, providerType, agen
23
59
  if (isOpencode) body.model = { providerID: 'opencode', modelID: modelId };
24
60
  else { body.providerID = 'kilo'; body.modelID = modelId; }
25
61
 
26
- let text = '';
62
+ const st = makeState();
63
+ const emit = ev => { (window.__debug[dbgKey].events ||= []).push({ ...ev, t: Date.now() }); };
64
+
27
65
  if (isOpencode) {
28
66
  const es = new EventSource(base + '/event');
29
- const textByPart = new Map();
30
67
  const assistantMsgs = new Set();
31
- let stepFinished = false;
32
68
  const pending = [];
33
69
  let resolveNext = null;
34
70
  const wake = () => { if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } };
71
+ const push = ev => { emit(ev); pending.push(ev); wake(); };
35
72
  es.onmessage = e => {
36
73
  try {
37
74
  const m = JSON.parse(e.data);
38
75
  if (m.type === 'message.updated') {
39
76
  const info = m.properties?.info;
40
- if (info?.sessionID === sessionId && info.role === 'assistant') assistantMsgs.add(info.id);
77
+ if (info?.sessionID === sessionId && info.role === 'assistant') { assistantMsgs.add(info.id); if (info.modelID) push({ type: 'model-info', modelID: info.modelID, providerID: info.providerID }); }
41
78
  } else if (m.type === 'message.part.updated') {
42
79
  const part = m.properties?.part;
43
80
  if (part?.sessionID !== sessionId || !assistantMsgs.has(part.messageID)) return;
44
- if (part.type === 'text') {
45
- const prior = textByPart.get(part.id) || '';
46
- const txt = part.text || '';
47
- if (txt.length > prior.length) { pending.push({ type:'text-delta', textDelta: txt.slice(prior.length) }); textByPart.set(part.id, txt); wake(); }
48
- } else if (part.type === 'step-finish') { stepFinished = true; wake(); }
81
+ const h = PART_HANDLERS[part.type];
82
+ if (h) h(part, st, push);
83
+ else push({ type: 'unknown-part', partType: part.type, id: part.id });
49
84
  }
50
85
  } catch (_) {}
51
86
  };
87
+ yield { type: 'status', message: 'POST /message' };
52
88
  const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
53
89
  window.__debug[dbgKey].lastStatus = msgRes.status;
90
+ yield { type: 'status', message: 'msg ' + msgRes.status };
54
91
  if (!msgRes.ok) { es.close(); throw new Error('message ' + msgRes.status + ': ' + await msgRes.text()); }
55
92
  const deadline = Date.now() + 180000;
56
93
  let graceUntil = 0;
57
94
  while (true) {
58
- if (pending.length) { const ev = pending.shift(); text += ev.textDelta; yield ev; continue; }
59
- if (stepFinished) { if (!graceUntil) graceUntil = Date.now() + 1500; if (Date.now() > graceUntil) break; }
95
+ if (pending.length) { yield pending.shift(); continue; }
96
+ if (st.stepFinished) { if (!graceUntil) graceUntil = Date.now() + 1500; if (Date.now() > graceUntil) break; }
60
97
  if (Date.now() > deadline) break;
61
98
  await new Promise(r => { resolveNext = r; setTimeout(r, 500); });
62
99
  }
63
100
  es.close();
64
101
  } else {
102
+ yield { type: 'status', message: 'POST /message' };
65
103
  const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
66
104
  window.__debug[dbgKey].lastStatus = msgRes.status;
105
+ yield { type: 'status', message: 'msg ' + msgRes.status };
67
106
  if (!msgRes.ok) throw new Error('message ' + msgRes.status + ': ' + await msgRes.text());
68
107
  const result = await msgRes.json();
69
108
  window.__debug[dbgKey].lastResult = result;
70
- for (const tp of (result.parts || []).filter(p => p.type === 'text')) { text += tp.text; yield { type: 'text-delta', textDelta: tp.text }; }
109
+ const info = result.info || {};
110
+ if (info.modelID) { const ev = { type: 'model-info', modelID: info.modelID, providerID: info.providerID }; emit(ev); yield ev; }
111
+ for (const part of (result.parts || [])) {
112
+ const h = PART_HANDLERS[part.type];
113
+ const pending = [];
114
+ const pushLocal = ev => { emit(ev); pending.push(ev); };
115
+ if (h) h(part, st, pushLocal);
116
+ else pushLocal({ type: 'unknown-part', partType: part.type, id: part.id });
117
+ for (const ev of pending) yield ev;
118
+ }
71
119
  }
120
+ yield { type: 'status', message: 'mirror sandbox' };
72
121
  const mirrored = await mirrorFromSandbox(fsBase);
73
122
  if (mirrored.length) {
74
123
  window.__debug[dbgKey].writes = mirrored;
124
+ for (const path of mirrored) yield { type: 'file-mirrored', path };
75
125
  window.showPreview?.();
76
126
  window.refreshPreview?.();
77
127
  }
package/docs/tui.css CHANGED
@@ -123,6 +123,21 @@ html, body { background: var(--paper); color: var(--ink); height: 100%; margin:
123
123
  font-size: 12px;
124
124
  }
125
125
  .tui-toolbar label { color: var(--ink-dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; }
126
+ .tui-agent-stats {
127
+ padding: 4px 2ch;
128
+ border-bottom: 1px solid var(--ink-hair);
129
+ background: var(--surface);
130
+ font-size: 10px;
131
+ color: var(--ink-dim);
132
+ text-transform: uppercase;
133
+ letter-spacing: 0.05em;
134
+ min-height: 18px;
135
+ font-family: var(--ff-mono);
136
+ white-space: nowrap;
137
+ overflow: hidden;
138
+ text-overflow: ellipsis;
139
+ }
140
+ .tui-agent-stats:empty { display: none; }
126
141
  .tui-msglist {
127
142
  flex: 1;
128
143
  overflow-y: auto;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thebird",
3
- "version": "1.2.90",
3
+ "version": "1.2.92",
4
4
  "description": "Anthropic SDK to Gemini streaming bridge — drop-in proxy that translates Anthropic message format and tool calls to Google Gemini",
5
5
  "scripts": {
6
6
  "start": "node serve.js"