import { expect, test } from 'bun:test'; import { render } from 'ink-testing-library'; import React from 'react'; import { createAskTool } from '../src/ask'; import { applySubagentEvent } from '../src/ui/App'; import { AskPanel, createAskBridge } from '../src/ui/Ask'; import { Markdown } from '../src/ui/Markdown'; import { SubagentPanel, TodoPanel, StatusBar, InfoPanel, ActiveTool, QueuePanel, ThinkingPanel, type SubagentView } from '../src/ui/Panels'; import type { SubagentEvent } from '../src/subagent'; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); test('markdown renders headings, bullets, and code distinctly', () => { const app = render(); const frame = app.lastFrame() ?? ''; expect(frame).toContain('Title'); expect(frame).toContain('item'); expect(frame).toContain('code();'); expect(frame).toContain('ts'); app.unmount(); }); test('markdown strips the markup characters from the rendered output', () => { const app = render(); const frame = app.lastFrame() ?? ''; expect(frame).toContain('bold'); expect(frame).toContain('code'); expect(frame).not.toContain('**'); expect(frame).not.toContain('`'); app.unmount(); }); test('the todo panel shows a progress bar and every status', () => { const app = render( , ); const frame = app.lastFrame() ?? ''; expect(frame).toContain('1/4'); expect(frame).toContain('1 blocked'); expect(frame).toContain('[x] first'); expect(frame).toContain('[~] second'); expect(frame).toContain('[ ] third'); expect(frame).toContain('[!] fourth'); expect(frame).toContain('waiting on the API key'); app.unmount(); }); test('the subagent panel shows the last few steps of a running agent', () => { const agents: SubagentView[] = [ { id: 'sub1', kind: 'explore', description: 'find auth handlers', steps: [ { tool: 'glob', summary: 'src/**/*.ts' }, { tool: 'grep', summary: 'login' }, { tool: 'read_file', summary: 'src/auth.ts' }, ], status: 'running', }, ]; const app = render(); const frame = app.lastFrame() ?? ''; expect(frame).toContain('explore'); expect(frame).toContain('find auth handlers'); expect(frame).toContain('3 steps'); expect(frame).toContain('grep(login)'); app.unmount(); }); test('a failed subagent shows its error', () => { const app = render( , ); expect(app.lastFrame()).toContain('model refused'); app.unmount(); }); test('an empty subagent panel renders nothing', () => { const app = render(); expect(app.lastFrame() ?? '').toBe(''); app.unmount(); }); test('the status bar reports model, agent, thinking, context, and spend', () => { const app = render( , ); const frame = app.lastFrame() ?? ''; expect(frame).toContain('gpt-5'); expect(frame).toContain('deep'); expect(frame).toContain('max'); expect(frame).toContain('1234'); expect(frame).toContain('$0.42'); expect(frame).toContain('13 tools'); app.unmount(); }); test('a context limit turns the raw token count into a percentage', () => { const app = render( , ); const frame = app.lastFrame() ?? ''; expect(frame).toContain('50% ctx'); expect(frame).not.toContain('60000'); app.unmount(); }); test('the context percentage is capped at 100 rather than running over', () => { const app = render( , ); expect(app.lastFrame()).toContain('100% ctx'); app.unmount(); }); test('the info panel renders a markdown body', () => { const app = render(); const frame = app.lastFrame() ?? ''; expect(frame).toContain('tools'); expect(frame).toContain('7 offered'); expect(frame).toContain('read_file'); expect(frame).not.toContain('`'); app.unmount(); }); test('the active tool line names the tool and the file it is touching', () => { const app = render(); const frame = app.lastFrame() ?? ''; expect(frame).toContain('read_file'); expect(frame).toContain('src/session.ts'); app.unmount(); }); test('the active tool line renders before the arguments have arrived', () => { const app = render(); expect(app.lastFrame()).toContain('grep'); app.unmount(); }); test('the active tool line shows several detail lines and caps the rest', () => { const detail = Array.from({ length: 9 }, (_, i) => `src/file${i}.ts`); const app = render(); const frame = app.lastFrame() ?? ''; // One line is never enough for a batch read: which paths are about to enter the // context is the whole point of showing it. expect(frame).toContain('src/file0.ts'); expect(frame).toContain('src/file5.ts'); expect(frame).toContain('3 more'); app.unmount(); }); test('thinking collapses to a token count, and expands on request', () => { const text = 'x'.repeat(1648); const collapsed = render(); expect(collapsed.lastFrame()).toContain('~412 tokens'); expect(collapsed.lastFrame()).not.toContain('xxxx'); collapsed.unmount(); const open = render(); const frame = open.lastFrame() ?? ''; expect(frame).toContain('second line'); expect(frame).toContain('collapse'); open.unmount(); }); test('no reasoning renders nothing at all', () => { const app = render(); expect(app.lastFrame() ?? '').toBe(''); app.unmount(); }); test('the queue panel counts what is waiting and lists it in order', () => { const app = render(); const frame = app.lastFrame() ?? ''; expect(frame).toContain('queued: 2'); expect(frame).toContain('1. first thing'); expect(frame).toContain('2. second thing'); app.unmount(); }); test('an empty queue renders nothing', () => { const app = render(); expect(app.lastFrame() ?? '').toBe(''); app.unmount(); }); const events: SubagentEvent[] = [ { type: 'start', id: 'a', kind: 'explore', description: 'first task' }, { type: 'step', id: 'a', tool: 'grep', summary: 'needle' }, { type: 'start', id: 'b', kind: 'review', description: 'second task' }, { type: 'end', id: 'a', ok: true, steps: 1 }, { type: 'error', id: 'b', message: 'exploded' }, ]; test('subagent events fold into the panel view', () => { const view = events.reduce(applySubagentEvent, [] as SubagentView[]); expect(view).toHaveLength(2); expect(view[0]).toMatchObject({ id: 'a', status: 'done', steps: [{ tool: 'grep', summary: 'needle' }] }); expect(view[1]).toMatchObject({ id: 'b', status: 'failed', error: 'exploded' }); }); test('a subagent result attaches to its step without clearing the panel state', () => { const started = applySubagentEvent([], { type: 'start', id: 'a', kind: 'explore', description: 'find auth' }); const stepped = applySubagentEvent(started, { type: 'step', id: 'a', tool: 'grep', summary: 'login' }); const view = applySubagentEvent(stepped, { type: 'result', id: 'a', tool: 'grep', summary: '2 hits', ok: true }); expect(view).toHaveLength(1); expect(view[0]).toMatchObject({ id: 'a', steps: [{ tool: 'grep', summary: 'login', outcome: '2 hits', ok: true }], }); }); test('an event for an unknown id is ignored rather than throwing', () => { const view = applySubagentEvent([], { type: 'step', id: 'ghost', tool: 'grep', summary: 'x' }); expect(view).toEqual([]); }); test('the ask tool refuses when nothing can answer', async () => { const t = createAskTool(undefined); expect( Promise.resolve(t.execute!({ question: 'which one?' } as never, { toolCallId: 'x', messages: [] } as never)), ).rejects.toThrow(/headless/); }); test('the ask tool returns the chosen options', async () => { const t = createAskTool(async () => ['Option B']); const out = await (t.execute!( { question: 'A or B?', options: [{ label: 'Option A' }, { label: 'Option B' }] } as never, { toolCallId: 'x', messages: [] } as never, ) as Promise); expect(out).toContain('Option B'); }); test('a dismissed question tells the model to decide for itself', async () => { const t = createAskTool(async () => undefined); const out = await (t.execute!({ question: 'anything?' } as never, { toolCallId: 'x', messages: [], } as never) as Promise); expect(out).toContain('dismissed'); expect(out).toContain('best judgement'); }); test('the ask bridge resolves undefined when no UI is bound', async () => { const bridge = createAskBridge(); expect(await bridge.ask({ question: 'q', multiple: false })).toBeUndefined(); }); test('the ask panel shows the question and its options', () => { const app = render( {}, }} />, ); const frame = app.lastFrame() ?? ''; expect(frame).toContain('shiro is asking'); expect(frame).toContain('Which storage layer?'); expect(frame).toContain('Postgres'); expect(frame).toContain('SQLite'); app.unmount(); }); test('choosing an option resolves the question', async () => { const answers: (string[] | undefined)[] = []; const app = render( answers.push(a), }} />, ); await wait(120); app.stdin.write('\r'); await wait(200); expect(answers).toEqual([['A']]); app.unmount(); }, 10_000); test('esc dismisses the question with no answer', async () => { const answers: (string[] | undefined)[] = []; const app = render( answers.push(a), }} />, ); await wait(120); app.stdin.write('\u001B'); await wait(200); expect(answers).toEqual([undefined]); app.unmount(); }, 10_000); test('a question with no options goes straight to free text', async () => { const answers: (string[] | undefined)[] = []; const app = render( answers.push(a) }} />, ); await wait(120); expect(app.lastFrame()).toContain('ype your answer'); for (const ch of 'shiro') { app.stdin.write(ch); await wait(40); } app.stdin.write('\r'); await wait(200); expect(answers).toEqual([['shiro']]); app.unmount(); }, 15_000);