Files
Muhammad Zakir Ramadhan f8c3cc2d8e Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates

Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
2026-09-17 17:49:19 +07:00

163 lines
4.5 KiB
TypeScript

import { expect, test } from 'bun:test';
import { render } from 'ink-testing-library';
import React from 'react';
import { MockLanguageModelV4 } from 'ai/test';
import { Session } from '../src/session';
import { App, createApprovalBridge, type AppHooks } from '../src/ui/App';
import { streamOf, testHooks, textChunks, usageOf } from './helpers';
const usage = usageOf(2);
const model = new MockLanguageModelV4({
doStream: async () => streamOf(textChunks('answer', usage)),
});
function mount(over: Partial<AppHooks> = {}) {
const bridge = createApprovalBridge();
const session = new Session({ model, askApproval: bridge.ask });
const app = render(<App session={session} bridge={bridge} header="hdr" hooks={testHooks(over)} />);
return { app, session };
}
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const DOWN = '\u001B[B';
const UP = '\u001B[A';
async function press(app: ReturnType<typeof render>, s: string, ms = 130) {
app.stdin.write(s);
await wait(ms);
}
test('typing a slash opens the menu listing every command', async () => {
const { app } = mount();
await wait(150);
expect(app.lastFrame()).not.toContain('/compact');
await press(app, '/');
const frame = app.lastFrame() ?? '';
expect(frame).toContain('/help');
expect(frame).toContain('/provider');
expect(frame).toContain('/compact');
expect(frame).toContain('tab complete');
app.unmount();
}, 10_000);
test('the menu narrows as more characters are typed', async () => {
const { app } = mount();
await wait(150);
await press(app, '/');
await press(app, 'c');
await press(app, 'o');
const frame = app.lastFrame() ?? '';
expect(frame).toContain('/compact');
expect(frame).toContain('/cost');
expect(frame).not.toContain('/provider');
app.unmount();
}, 10_000);
test('tab completes the highlighted entry into the input', async () => {
const { app } = mount();
await wait(150);
await press(app, '/');
await press(app, 'c');
await press(app, 'o');
await press(app, 's');
await press(app, '\t');
const frame = app.lastFrame() ?? '';
expect(frame).toContain('> /cost');
expect(frame).not.toContain('tab complete');
app.unmount();
}, 10_000);
test('up and down move the highlight and wrap around', async () => {
const { app } = mount();
await wait(150);
await press(app, '/');
await press(app, 'm');
await press(app, 'o');
await press(app, UP);
await press(app, '\t');
expect(app.lastFrame()).toContain('> /model');
expect(app.lastFrame()).not.toContain('/models ');
app.unmount();
}, 10_000);
test('enter on an open menu runs the highlighted command, not the raw text', async () => {
const seen: string[] = [];
const { app } = mount({
listModels: async () => {
seen.push('listModels');
return { models: [] };
},
});
await wait(150);
await press(app, '/');
await press(app, 'm');
await press(app, 'o');
await press(app, '\r', 500);
expect(seen).toEqual(['listModels']);
expect(app.lastFrame()).toContain('> /models');
app.unmount();
}, 10_000);
test('esc dismisses the menu and leaves the typed text alone', async () => {
const { app } = mount();
await wait(150);
await press(app, '/');
await press(app, 'c');
expect(app.lastFrame()).toContain('/compact');
await press(app, '\u001B', 200);
const frame = app.lastFrame() ?? '';
expect(frame).not.toContain('tab complete');
expect(frame).toContain('> /c');
app.unmount();
}, 10_000);
test('an unknown slash word shows no menu and reports on submit', async () => {
const { app } = mount();
await wait(150);
for (const ch of '/zzz') await press(app, ch, 90);
expect(app.lastFrame()).not.toContain('tab complete');
await press(app, '\r', 400);
expect(app.lastFrame()).toContain('unknown command /zzz');
app.unmount();
}, 10_000);
test('the menu closes once an argument is being typed', async () => {
const { app } = mount();
await wait(150);
for (const ch of '/resume') await press(app, ch, 70);
expect(app.lastFrame()).toContain('/resume <id>');
await press(app, ' ', 150);
expect(app.lastFrame()).not.toContain('tab complete');
app.unmount();
}, 15_000);
test('plain prose never triggers the menu and still reaches the model', async () => {
const { app, session } = mount();
await wait(150);
for (const ch of 'hello') await press(app, ch, 60);
expect(app.lastFrame()).not.toContain('tab complete');
await press(app, '\r', 500);
expect(app.lastFrame()).toContain('answer');
expect(session.messages[0]?.content).toBe('hello');
app.unmount();
}, 15_000);