LLeMbas CLI 1.0.0
ci / check (push) Waiting to run

The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+988
View File
@@ -0,0 +1,988 @@
import { afterEach, expect, test } from "bun:test"
import { delta, toolCall, usage } from "../fake-provider.ts"
import { tui } from "./harness.tsx"
let t: Awaited<ReturnType<typeof tui>> | undefined
afterEach(() => t?.stop())
test("/model opens the picker; ↓ + enter switches", async () => {
t = await tui([])
await t.input.typeText("/model")
t.input.pressEnter()
expect(await t.until("↑↓ move")).toBe(true)
t.input.pressArrow("down")
await t.settle()
t.input.pressEnter()
await t.settle()
expect(t.app.engine.model.ref).toBe("fake/other")
expect(t.frame()).toContain("model: fake/other")
})
test("a prompt runs; a bash call asks; y allows it; the answer renders as markdown", async () => {
t = await tui([
{ chunks: [delta({ content: "Checking." }), toolCall(0, "c1", "bash", '{"command":"echo hi > out.txt","description":"Write out.txt"}'), usage(9000, 40)] },
{ chunks: [delta({ content: "## Done\n\nWrote **out.txt**." }), usage(12000, 60)] },
])
await t.input.typeText("make a file")
t.input.pressEnter()
expect(await t.until("Allow once")).toBe(true)
expect(t.frame()).toContain("echo hi > out.txt")
await t.input.typeText("y")
expect(await t.until("Wrote out.txt.")).toBe(true)
const f = t.frame()
expect(f).toContain("❯ Ask") // the y that answered did not also land in the prompt
expect(f).toContain("┃ make a file")
expect(f).toContain("┊ 💻 bash Write out.txt · exit 0")
expect(f).not.toContain("## Done") // heading marker concealed
expect(f).toContain("ctx 12k/33k 37%")
expect(await Bun.file(`${t.cwd}/out.txt`).text()).toBe("hi\n")
})
test("shift+tab cycles manual → edit → plan → manual; the status bar follows", async () => {
t = await tui([])
expect(t.frame()).toContain(" manual · fake/coder · low")
for (const want of ["edit", "plan", "manual"] as const) {
t.input.pressTab({ shift: true })
await t.settle()
expect(t.app.engine.mode).toBe(want)
expect(t.frame()).toContain(` ${want} · fake/coder`)
}
})
test("deny with a reason sends the reason to the model", async () => {
t = await tui([
{ chunks: [toolCall(0, "c1", "bash", '{"command":"rm hello.txt"}')] },
{ chunks: [delta({ content: "Leaving it." })] },
])
await t.input.typeText("delete it")
t.input.pressEnter()
expect(await t.until("Allow once")).toBe(true)
await t.input.typeText("r")
await t.settle()
await t.input.typeText("keep the file")
t.input.pressEnter()
expect(await t.until("Leaving it.")).toBe(true)
expect(t.fake.requests[1].messages.at(-1).content).toContain("keep the file")
expect(t.frame()).toContain("⛔")
})
test("e edits the command before allowing it: the edited line runs, and the model is told", async () => {
t = await tui([
{ chunks: [toolCall(0, "c1", "bash", '{"command":"echo hi > out.txt"}')] },
{ chunks: [delta({ content: "Noted." })] },
])
await t.input.typeText("make a file")
t.input.pressEnter()
expect(await t.until("Edit, then allow")).toBe(true)
await t.input.typeText("e")
expect(await t.until("run › echo hi > out.txt")).toBe(true)
await t.input.typeText(" && echo edited >> out.txt")
t.input.pressEnter()
expect(await t.until("Noted.")).toBe(true)
expect(await Bun.file(`${t.cwd}/out.txt`).text()).toBe("hi\nedited\n")
expect(t.fake.requests[1].messages.at(-1).content).toContain("The user changed the command before allowing it. What ran: echo hi > out.txt && echo edited >> out.txt")
})
test("↑ recalls the previous prompt", async () => {
t = await tui([{ chunks: [delta({ content: "one" })] }])
await t.input.typeText("first prompt")
t.input.pressEnter()
expect(await t.until("one")).toBe(true)
t.input.pressArrow("up")
await t.settle()
expect(t.frame()).toContain("❯ first prompt")
})
test("/compact replaces the conversation with a summary", async () => {
t = await tui([
{ chunks: [delta({ content: "The answer is 42." })] },
{ chunks: [delta({ content: "## What we are doing\nFinding the answer. It is 42." })] },
])
await t.input.typeText("what is the answer")
t.input.pressEnter()
expect(await t.until("The answer is 42.")).toBe(true)
await t.input.typeText("/compact")
t.input.pressEnter()
expect(await t.until("compacted")).toBe(true)
expect(t.fake.requests[1].messages[0].content).toContain("## Transcript")
expect(t.app.engine.messages[0]).toMatchObject({ role: "user" })
expect(JSON.stringify(t.app.engine.messages[0])).toContain("It is 42.")
})
test("/undo restores the files and puts the prompt back; /redo re-applies; /diff shows the change", async () => {
t = await tui([
{ chunks: [toolCall(0, "c1", "read", '{"path":"hello.txt"}')] },
{ chunks: [toolCall(0, "c2", "edit", JSON.stringify({ path: "hello.txt", old: "world", new: "there" }))] },
{ chunks: [delta({ content: "Changed it." })] },
])
t.app.engine.mode = "edit"
await t.input.typeText("change the greeting")
t.input.pressEnter()
expect(await t.until("Changed it.")).toBe(true)
expect(await Bun.file(`${t.cwd}/hello.txt`).text()).toBe("hello there\n")
await t.input.typeText("/diff")
t.input.pressEnter()
expect(await t.until("changes in this session")).toBe(true)
await t.input.typeText("/undo")
t.input.pressEnter()
expect(await t.until("undone — restored 1 file: hello.txt")).toBe(true)
expect(await Bun.file(`${t.cwd}/hello.txt`).text()).toBe("hello world\n")
expect(t.frame()).toContain("❯ change the greeting")
expect(t.frame()).not.toContain("Changed it.")
expect(t.app.engine.messages).toHaveLength(0)
// clear the restored prompt, then redo
for (let i = 0; i < 20; i++) t.input.pressBackspace()
await t.input.typeText("/redo")
t.input.pressEnter()
expect(await t.until("redone")).toBe(true)
expect(await Bun.file(`${t.cwd}/hello.txt`).text()).toBe("hello there\n")
expect(t.frame()).toContain("Changed it.")
})
test("/commit drafts a message from the session's files, and commits only those on confirm", async () => {
t = await tui([
{ chunks: [toolCall(0, "c1", "write", JSON.stringify({ path: "notes.md", content: "# notes\n" }))] },
{ chunks: [delta({ content: "Wrote notes." })] },
{ chunks: [delta({ content: "```\nAdd notes file\n```" })] },
])
const git = (...a: string[]) => Bun.spawnSync(["git", "-C", t!.cwd, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...a])
git("add", "hello.txt")
git("commit", "-q", "-m", "Initial")
Bun.write(`${t.cwd}/unrelated.txt`, "not mine\n")
t.app.engine.mode = "edit"
await t.input.typeText("write notes")
t.input.pressEnter()
expect(await t.until("Wrote notes.")).toBe(true)
await t.input.typeText("/commit")
t.input.pressEnter()
expect(await t.until("Commit 1 file?")).toBe(true)
expect(t.frame()).toContain("Add notes file")
process.env.GIT_AUTHOR_NAME = process.env.GIT_COMMITTER_NAME = "t"
process.env.GIT_AUTHOR_EMAIL = process.env.GIT_COMMITTER_EMAIL = "t@t"
t.input.pressEnter()
expect(await t.until("committed")).toBe(true)
const log = git("log", "--format=%s", "--name-only", "-1").stdout.toString()
expect(log).toContain("Add notes file")
expect(log).toContain("notes.md")
expect(log).not.toContain("unrelated.txt")
})
test("the question card: pick by number, multiple with space, ask back — and the model gets it all", async () => {
t = await tui([
{
chunks: [
toolCall(
0,
"q1",
"ask_user",
JSON.stringify({
why: "Before I start.",
questions: [
{ header: "DB", question: "Which database?", options: [{ label: "SQLite" }, { label: "PostgreSQL", recommended: true, description: "the server one" }] },
{ header: "Layers", question: "Which layers?", multiple: true, options: ["API", "CLI", "Web"] },
{ header: "Deploy", question: "Where?", options: ["Docker", "Bare"] },
],
}),
),
],
},
{ chunks: [delta({ content: "Docker adds a minute. Asking again." })] },
])
await t.input.typeText("plan it")
t.input.pressEnter()
expect(await t.until("Which database?")).toBe(true)
const f = t.frame()
expect(f).toContain("Before I start.")
expect(f.indexOf("PostgreSQL (Recommended)")).toBeLessThan(f.indexOf("SQLite")) // recommended first
expect(f).toContain("Something else…")
expect(f).toContain("Ask back…")
await t.input.typeText("1")
expect(await t.until("Which layers?")).toBe(true)
t.input.pressKey(" ")
t.input.pressArrow("down")
await t.settle()
t.input.pressKey(" ")
t.input.pressEnter()
expect(await t.until("Where?")).toBe(true)
for (let i = 0; i < 3; i++) t.input.pressArrow("down")
await t.settle()
t.input.pressEnter()
await t.settle()
await t.input.typeText("what does docker cost?")
t.input.pressEnter()
expect(await t.until("Asking again.")).toBe(true)
const result = t.fake.requests[1].messages.at(-1).content as string
expect(result).toContain("→ PostgreSQL (the one you recommended)")
expect(result).toContain("→ API, CLI")
expect(result).toContain('↩ counter-question: "what does docker cost?"')
})
test("esc dismisses the questions; the todo panel shows open work", async () => {
t = await tui([
{ chunks: [toolCall(0, "t1", "todo", JSON.stringify({ todos: [{ content: "read the code", status: "completed" }, { content: "fix the bug", status: "in_progress" }, { content: "run tests", status: "pending" }] })), toolCall(1, "q1", "ask_user", '{"question":"Go on?","options":["yes","no"]}')] },
{ chunks: [delta({ content: "Carrying on." })] },
])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Go on?")).toBe(true)
expect(t.frame()).toContain("todo 1/3")
expect(t.frame()).toContain("▸ fix the bug")
t.input.pressEscape()
expect(await t.until("Carrying on.")).toBe(true)
expect(t.fake.requests[1].messages.at(-1).content).toContain("dismissed")
})
test("@ lists files; tab takes one; the model receives the file", async () => {
t = await tui([{ chunks: [delta({ content: "It says hello world." })] }])
await t.input.typeText("what does @hel")
expect(await t.until("hello.txt")).toBe(true)
t.input.pressTab()
await t.settle()
expect(t.frame()).toContain("❯ what does @hello.txt")
await t.input.typeText("say?")
t.input.pressEnter()
expect(await t.until("It says hello world.")).toBe(true)
const user = t.fake.requests[0].messages.at(-1).content as string
expect(user).toContain("what does @hello.txt say?")
expect(user).toContain('<file path="hello.txt">\n1: hello world\n</file>')
expect(t.frame()).toContain("attached hello.txt")
expect(t.frame()).not.toContain("<file path")
})
test("/ fuzzy-lists commands; a custom command sends its expanded body", async () => {
const { mkdirSync, writeFileSync } = await import("node:fs")
const { join } = await import("node:path")
const { paths } = await import("../../src/config/paths.ts")
mkdirSync(join(paths.config, "commands"), { recursive: true })
writeFileSync(join(paths.config, "commands", "explain.md"), "---\ndescription: explain a file\n---\nExplain $1 in one line.\n")
t = await tui([{ chunks: [delta({ content: "A greeting." })] }])
await t.input.typeText("/und")
expect(await t.until("/undo")).toBe(true)
for (let i = 0; i < 4; i++) t.input.pressBackspace()
await t.input.typeText("/expl")
expect(await t.until("explain a file")).toBe(true)
t.input.pressTab()
await t.input.typeText("hello.txt")
t.input.pressEnter()
expect(await t.until("A greeting.")).toBe(true)
expect(t.fake.requests[0].messages.at(-1).content).toContain("Explain hello.txt in one line.")
expect(t.frame()).toContain("┃ /explain hello.txt")
})
test("!command runs at once and its output goes with the next prompt", async () => {
t = await tui([{ chunks: [delta({ content: "Seen it." })] }])
await t.input.typeText("!echo from-the-shell")
t.input.pressEnter()
expect(await t.until("goes to the model with your next message")).toBe(true)
expect(t.frame()).toContain("! echo from-the-shell")
await t.input.typeText("did you see that?")
t.input.pressEnter()
expect(await t.until("Seen it.")).toBe(true)
const user = t.fake.requests[0].messages.at(-1).content as string
expect(user).toContain('<shell command="echo from-the-shell">')
expect(user).toContain("from-the-shell")
})
test("thinking stops the clock when the model starts a call, even if the call then waits for approval", async () => {
t = await tui([
{ chunks: [delta({ reasoning_content: "I should write a file." }), toolCall(0, "c1", "bash", '{"command":"echo x > y.txt"}')] },
{ chunks: [delta({ content: "done" })] },
])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Allow once")).toBe(true)
await Bun.sleep(1200)
await t.settle()
expect(t.frame()).toMatch(/Thought for 0\.\ds · ~\d+ tokens?/)
await t.input.typeText("n")
expect(await t.until("done")).toBe(true)
})
test("pasting an image's path (quoted, as a file manager does) inserts an @reference", async () => {
t = await tui([])
await Bun.write(`${t.cwd}/my shot.png`, "x")
await t.input.pasteBracketedText(`'${t.cwd}/my shot.png'`)
await t.settle()
expect(t.frame()).toContain(`@${t.cwd}/my\\ shot.png`)
})
test("the plan card: the plan shown as markdown; a approves and the status bar says edit", async () => {
t = await tui([
{ chunks: [toolCall(0, "w1", "write", JSON.stringify({ path: ".agent/plans/2026-09-29-x.md", content: "# The plan\n\n## Changes\n- **one** thing\n" }))] },
{ chunks: [toolCall(0, "x1", "plan_submit", '{"path":".agent/plans/2026-09-29-x.md"}')] },
{ chunks: [delta({ content: "Implementing now." })] },
])
await t.input.typeText("/plan tidy up")
t.input.pressEnter()
expect(await t.until("Approve — edit mode")).toBe(true)
expect(await t.until("one thing")).toBe(true) // markdown is parsed off-thread; it draws a moment later
const f = t.frame()
expect(f).toContain("plan · .agent/plans/2026-09-29-x.md")
const card = f.slice(f.indexOf("╭─ plan ·"))
expect(card).toContain("The plan")
expect(card).toContain("one thing")
expect(card).not.toContain("**one**") // rendered, not raw
expect(f).toContain(" plan · fake/coder")
await t.input.typeText("a")
expect(await t.until("Implementing now.")).toBe(true)
expect(t.frame()).toContain(" edit · fake/coder")
})
test("thinking is one line — its text is not shown; the provider's count of it is; ctrl+r opens it", async () => {
t = await tui([
{
chunks: [
delta({ reasoning_content: "First I weigh the options.\nThen the SECRET-INNER-STEP." }),
delta({ content: "The answer." }),
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 60, completion_tokens_details: { reasoning_tokens: 1234 } } },
],
},
])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("The answer.")).toBe(true)
expect(await t.until("· 1,234 tokens")).toBe(true)
expect(t.frame()).toMatch(/Thought for \d+\.\ds · 1,234 tokens/)
expect(t.frame()).not.toContain("SECRET-INNER-STEP")
t.input.pressKey("r", { ctrl: true })
expect(await t.until("SECRET-INNER-STEP")).toBe(true)
})
test("while it thinks: the thought says only Thinking…; the line at the bottom has the verb, the task's time and tokens", async () => {
t = await tui([{ gapMs: 1500, chunks: [delta({ reasoning_content: "Weighing it up: HIDDEN-WHILE-THINKING and more words here." }), delta({ content: "Done thinking." })] }])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("~15 tokens")).toBe(true)
const frame = t.frame()
expect(frame).toMatch(/^\s*Thinking\.{1,3}\s*$/m)
expect(frame).toMatch(/(pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|reasoning|analyzing|synthesizing|formulating)… \d+s · ~15 tokens · esc to stop/)
expect(frame).not.toContain("HIDDEN-WHILE-THINKING")
expect(await t.until("Done thinking.")).toBe(true)
expect(t.frame()).toMatch(/Thought for \d\.\ds · ~15 tokens/)
})
test("the line at the bottom counts the whole task: the clock and the tokens go on across steps", async () => {
t = await tui([
{ chunks: [toolCall(0, "c1", "list", "{}"), { choices: [], usage: { prompt_tokens: 10, completion_tokens: 40 } }] },
{ gapMs: 1200, chunks: [delta({ content: "Half" }), delta({ content: " way." }), { choices: [], usage: { prompt_tokens: 20, completion_tokens: 25 } }] },
])
await t.input.typeText("go")
t.input.pressEnter()
// The first step's 40 tokens, then the second's reply growing on top of them.
expect(await t.until("Half")).toBe(true)
// The spinner line redraws on its own tick, slower than the reply's text: on a slow machine the
// frame that first shows "Half" can still carry the line from before. The 1.2 s gap holds this
// state, so wait for the line within it rather than reading the first frame.
const counted = /writing… \d+s · ~4\d tokens · esc to stop/
for (let i = 0; i < 20 && !counted.test(t.frame()); i++) await Bun.sleep(50), await t.settle()
expect(t.frame()).toMatch(counted)
expect(await t.until("way.")).toBe(true)
for (let i = 0; i < 60 && t.frame().includes("esc to stop"); i++) await Bun.sleep(100), await t.settle()
expect(t.frame()).not.toContain("esc to stop")
expect(t.view.taskTokens()).toEqual({ n: 65, estimated: false })
})
test("many reasoning blocks do not pile up resize listeners (Node's leak warning printed over the screen)", async () => {
const turn = (n: number) => ({ chunks: [delta({ reasoning_content: `thinking ${n}` }), delta({ content: `answer ${n}` })] })
t = await tui(Array.from({ length: 15 }, (_, i) => turn(i)))
const before = t.setup.renderer.listenerCount("resize")
for (let i = 0; i < 15; i++) {
await t.input.typeText(`q${i}`)
t.input.pressEnter()
expect(await t.until(`answer ${i}`)).toBe(true)
}
expect(t.setup.renderer.listenerCount("resize")).toBeLessThanOrEqual(before + 1)
}, 60_000)
test("/review sends the session's diff with the review brief in plan mode, then the mode is back", async () => {
t = await tui([
{ chunks: [toolCall(0, "c1", "read", '{"path":"hello.txt"}')] },
{ chunks: [toolCall(0, "c2", "edit", JSON.stringify({ path: "hello.txt", old: "world", new: "there" }))] },
{ chunks: [delta({ content: "Changed." })] },
{ chunks: [delta({ content: "No findings." })] },
])
t.app.engine.mode = "edit"
await t.input.typeText("change it")
t.input.pressEnter()
expect(await t.until("Changed.")).toBe(true)
await t.input.typeText("/review")
t.input.pressEnter()
expect(await t.until("No findings.")).toBe(true)
const req = t.fake.requests.at(-1)
expect(req.messages[0].content).toContain("Permission mode: plan")
const user = req.messages.at(-1).content as string
expect(user).toContain("Findings first, ordered by severity")
expect(user).toContain("+hello there")
expect(t.frame()).toContain("┃ /review")
expect(t.app.engine.mode).toBe("edit")
})
test("/decide records a decision; /tasks shows the board", async () => {
t = await tui([])
const { mkdirSync } = await import("node:fs")
mkdirSync(`${t.cwd}/.agent`)
await t.input.typeText("/decide Storage: SQLite, one file")
t.input.pressEnter()
expect(await t.until("recorded:")).toBe(true)
expect(await Bun.file(`${t.cwd}/.agent/decisions.md`).text()).toContain("**Decision:** SQLite, one file")
await t.input.typeText("/tasks")
t.input.pressEnter()
expect(await t.until("the board is empty")).toBe(true)
})
test("a skill is a /command: listed in the palette, and it sends the skill with the instruction", async () => {
const { mkdirSync, writeFileSync, rmSync } = await import("node:fs")
const { join } = await import("node:path")
const { paths } = await import("../../src/config/paths.ts")
const dir = join(paths.config, "skills", "tidy-imports")
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, "SKILL.md"), "---\nname: tidy-imports\ndescription: Use when imports need sorting.\n---\n\nSort imports alphabetically.\n")
t = await tui([{ chunks: [delta({ content: "Sorted." })] }])
await t.input.typeText("/tidy")
expect(await t.until("Use when imports need sorting.")).toBe(true)
t.input.pressTab()
await t.input.typeText("in app.ts")
t.input.pressEnter()
expect(await t.until("Sorted.")).toBe(true)
const sent = t.fake.requests[0].messages.at(-1).content
expect(sent).toContain('invoked the "tidy-imports" skill')
expect(sent).toContain("Sort imports alphabetically.")
expect(sent).toContain("The user's instruction with it: in app.ts")
// the skills list was in the system prompt the session started with
expect(t.fake.requests[0].messages[0].content).toContain("- tidy-imports: Use when imports need sorting.")
rmSync(join(paths.config, "skills"), { recursive: true, force: true })
})
test("/personality picks one and remembers it; /memory lists entries, and forgets or changes one", async () => {
const { readFileSync, rmSync, existsSync } = await import("node:fs")
const { join } = await import("node:path")
const { paths } = await import("../../src/config/paths.ts")
t = await tui([])
await t.input.typeText("/personality pirate")
t.input.pressEnter()
expect(await t.until("personality: one of none, concise")).toBe(true)
await t.input.typeText("/personality formal")
t.input.pressEnter()
expect(await t.until("personality: formal")).toBe(true)
expect(readFileSync(join(paths.config, "config.yaml"), "utf8")).toContain("personality: formal")
await t.input.typeText("/memory")
t.input.pressEnter()
expect(await t.until("nothing remembered yet")).toBe(true)
t.app.memory.apply("user", [{ action: "add", content: "The user is called Ana." }, { action: "add", content: "Ana likes tabs." }])
await t.input.typeText("/memory")
t.input.pressEnter()
expect(await t.until("[user] The user is called Ana.")).toBe(true)
await t.input.typeText("tabs")
await t.settle()
t.input.pressEnter()
expect(await t.until("Forget it")).toBe(true)
t.input.pressEnter()
expect(await t.until("user: forgotten")).toBe(true)
expect(t.app.memory.entries("user")).toEqual(["The user is called Ana."])
if (existsSync(join(paths.config, "config.yaml"))) rmSync(join(paths.config, "config.yaml"))
rmSync(t.app.memory.file("user"), { force: true })
}, 30_000)
test("MCP: a server's prompt is a /server:prompt command; /mcp lists the server; its tool line fits at 80 columns", async () => {
const { join } = await import("node:path")
const server = join(import.meta.dir, "..", "fixtures", "mcp", "server.ts")
t = await tui(
[
{ chunks: [toolCall(0, "c1", "mcp__fix__echo", '{"text":"hi"}')] },
{ chunks: [delta({ content: "Greeted." })] },
],
{ width: 80, height: 24 },
`mode: unrestricted\nmcp:\n fix:\n command: ["${process.execPath}", "${server}"]\n`,
)
await t.app.mcpReady
await t.input.typeText("/fix:gr")
expect(await t.until("Greet someone")).toBe(true)
t.input.pressTab()
await t.input.typeText("Jaro")
t.input.pressEnter()
expect(await t.until("Greeted.")).toBe(true)
expect(t.fake.requests[0].messages.at(-1).content).toContain("Say hello to Jaro.")
expect(t.frame()).toContain("🔌")
for (const line of t.frame().split("\n")) expect(Bun.stringWidth(line)).toBeLessThanOrEqual(80)
await t.input.typeText("/mcp")
t.input.pressEnter()
expect(await t.until("● fix local")).toBe(true)
}, 20_000)
test("MCP: a server's prompt cannot attach a file by naming it with @", async () => {
const { join } = await import("node:path")
const { mkdtempSync, writeFileSync } = await import("node:fs")
const { tmpdir } = await import("node:os")
const server = join(import.meta.dir, "..", "fixtures", "mcp", "server.ts")
const secret = join(mkdtempSync(join(tmpdir(), "ph-secret-")), "id_key")
writeFileSync(secret, "PRIVATE-KEY-MATERIAL")
t = await tui([{ chunks: [delta({ content: "Looked." })] }], { width: 100, height: 30 }, `mcp:\n fix:\n command: ["${process.execPath}", "${server}"]\n`)
await t.app.mcpReady
await t.input.typeText(`/fix:peek ${secret}`)
t.input.pressEnter()
expect(await t.until("Looked.")).toBe(true)
const sent = JSON.stringify(t.fake.requests[0].messages.at(-1))
expect(sent).toContain("Look at @")
expect(sent).not.toContain("PRIVATE-KEY-MATERIAL")
}, 20_000)
test("voice: ctrl+t records, silence ends it, what was said lands in the prompt; /speak and /voice", async () => {
const { join } = await import("node:path")
const stt = Bun.serve({ port: 0, fetch: () => Response.json({ text: "Fix the failing test." }) })
const mic = join(import.meta.dir, "..", "fixtures", "voice", "mic.ts")
try {
t = await tui([], { width: 100, height: 30 }, `voice:\n stt: { base_url: "http://127.0.0.1:${stt.port}/v1" }\n tts: { base_url: "http://127.0.0.1:${stt.port}/v1" }\n recorder_command: ["${process.execPath}", "${mic}", "1"]\n silence_seconds: 0.4\n`)
expect(t.frame()).toContain("🎤 Ctrl+T")
t.input.pressKey("t", { ctrl: true })
expect(await t.until("● REC")).toBe(true)
expect(await t.until("❯ Fix the failing test.", 8000)).toBe(true)
expect(t.fake.requests).toHaveLength(0) // drafted, not sent
for (let i = 0; i < 25; i++) t.input.pressBackspace()
await t.input.typeText("/speak")
t.input.pressEnter()
expect(await t.until("replies are spoken")).toBe(true)
expect(await t.until("🔊 on")).toBe(true)
await t.input.typeText("/voice")
t.input.pressEnter()
expect(await t.until("input: openai · http://127.0.0.1")).toBe(true)
} finally {
stt.stop(true)
}
}, 20_000)
test("/release: the plan, then the dialog; Release commits and tags, pushing nothing; /changelog sends the commits", async () => {
const { writeFileSync } = await import("node:fs")
const { join } = await import("node:path")
const { git } = await import("../../src/git/run.ts")
t = await tui([{ chunks: [delta({ content: "Updated the changelog." })] }])
const d = t.cwd
writeFileSync(join(d, "package.json"), '{ "name": "demo", "version": "0.1.0" }\n')
writeFileSync(join(d, "CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n\n### Added\n- Things.\n")
for (const a of [["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["config", "tag.gpgsign", "false"], ["add", "package.json", "CHANGELOG.md", "hello.txt"], ["commit", "-q", "-m", "Add the demo"]]) git(d, a)
;(t.app.project as any).gitRoot = d
await t.input.typeText("/changelog")
t.input.pressEnter()
expect(await t.until("Updated the changelog.")).toBe(true)
expect(t.fake.requests[0].messages.at(-1).content).toContain("- Add the demo")
await t.input.typeText("/release minor")
t.input.pressEnter()
expect(await t.until("Release v0.2.0?")).toBe(true)
expect(t.frame()).toContain("package.json: 0.1.0 → 0.2.0")
t.input.pressEnter()
expect(await t.until("nothing pushed")).toBe(true)
expect(git(d, ["tag", "-l", "--format=%(contents)", "v0.2.0"]).out).toBe("demo 0.2.0\n\n### Added\n- Things.")
})
test("asking about an edit shows the change itself, not only the file", async () => {
t = await tui([
{ chunks: [toolCall(0, "r1", "read", '{"path":"hello.txt"}')] },
{ chunks: [toolCall(0, "e1", "edit", JSON.stringify({ path: "hello.txt", old: "hello world", new: "hello lembas" }))] },
{ chunks: [delta({ content: "Changed." })] },
])
await t.input.typeText("change it")
t.input.pressEnter()
expect(await t.until("Allow once")).toBe(true)
const f = t.frame()
expect(f).toContain("hello.txt +1 −1")
expect(f).toMatch(/-\s*hello world/)
expect(f).toMatch(/\+\s*hello lembas/)
expect(await Bun.file(`${t.cwd}/hello.txt`).text()).toBe("hello world\n") // nothing written while asking
await t.input.typeText("y")
expect(await t.until("Changed.")).toBe(true)
expect(await Bun.file(`${t.cwd}/hello.txt`).text()).toBe("hello lembas\n")
})
test("a change that cannot apply says so while asking", async () => {
t = await tui([
{ chunks: [toolCall(0, "r1", "read", '{"path":"hello.txt"}')] },
{ chunks: [toolCall(0, "e1", "edit", JSON.stringify({ path: "hello.txt", old: "not there", new: "x" }))] },
{ chunks: [delta({ content: "ok" })] },
])
await t.input.typeText("change it")
t.input.pressEnter()
expect(await t.until("Allow once")).toBe(true)
expect(t.frame()).toContain("this will fail:")
})
test("/copy copies the last reply and says how", async () => {
t = await tui([{ chunks: [delta({ content: "## Plan\n\nUse **ws**." })] }])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Use ws.")).toBe(true)
await t.input.typeText("/copy")
t.input.pressEnter()
expect(await t.until("copied 20 characters")).toBe(true)
})
test("dragging over a reply copies what it selects", async () => {
t = await tui([{ chunks: [delta({ content: "SELECTME please copy this line" })] }])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("SELECTME")).toBe(true)
const rows = t.frame().split("\n")
const y = rows.findIndex((l) => l.includes("SELECTME"))
const x = rows[y]!.indexOf("SELECTME")
await t.setup.mockMouse.drag(x, y, x + 8, y) // both ends are in: 9 cells, "SELECTME "
expect(await t.until("copied 9 characters")).toBe(true)
// In the status bar, not the conversation; and gone again by itself.
expect(t.frame().split("\n").filter((l) => l.trim()).at(-1)).toContain("copied 9 characters")
expect(t.view.state.items.some((i) => JSON.stringify(i).includes("copied 9"))).toBe(false)
})
test("copying while the model thinks does not split the thinking block", async () => {
t = await tui([
{ chunks: [delta({ content: "FIRST answer" })] },
{ chunks: [delta({ reasoning_content: "weighing it" }), ...Array.from({ length: 40 }, () => delta({ reasoning_content: " more" })), delta({ content: "SECOND answer" })], gapMs: 30 },
])
await t.input.typeText("one")
t.input.pressEnter()
expect(await t.until("FIRST answer")).toBe(true)
await t.input.typeText("two")
t.input.pressEnter()
// While the thought streams: copy the first answer.
const end = Date.now() + 3000
while (!t.view.state.items.some((i) => i.kind === "reasoning" && i.streaming) && Date.now() < end) await t.settle(20)
await t.input.typeText("/copy 1")
t.input.pressEnter()
expect(await t.until("copied 12 characters")).toBe(true)
expect(await t.until("SECOND answer")).toBe(true)
expect(t.view.state.items.filter((i) => i.kind === "reasoning")).toHaveLength(1)
expect(t.view.state.items.some((i) => JSON.stringify(i).includes("copied"))).toBe(false)
})
test("/icons plain swaps every picture for a one-column symbol, and is saved", async () => {
t = await tui([{ chunks: [toolCall(0, "r1", "read", '{"path":"hello.txt"}')] }, { chunks: [delta({ content: "ok" })] }])
await t.input.typeText("/icons plain")
t.input.pressEnter()
expect(await t.until("icons: plain")).toBe(true)
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("┊ » read")).toBe(true)
expect(t.frame()).not.toContain("📖")
const { readFileSync } = await import("node:fs")
const { join } = await import("node:path")
const { paths } = await import("../../src/config/paths.ts")
expect(readFileSync(join(paths.config, "config.yaml"), "utf8")).toContain("icons: plain")
})
test("the frame rate follows every capability answer, not just the first", async () => {
const { frameRateFor } = await import("../../src/tui/index.tsx")
const r = { targetFps: 30, maxFps: 60 }
frameRateFor(r, { sync: false }) // an early answer, before the one about mode 2026
expect(r).toEqual({ targetFps: 12, maxFps: 12 })
frameRateFor(r, { sync: true }) // kitty, WezTerm, foot… once their answer arrives
expect(r).toEqual({ targetFps: 30, maxFps: 60 })
})
test("/terminal says what was found out about the terminal", async () => {
t = await tui([])
await t.input.typeText("/terminal")
t.input.pressEnter()
// The test renderer asks nothing of a terminal; the frame rate and mouse are said regardless.
expect(await t.until("frame rate:")).toBe(true)
})
test("/checkpoint saves the files; /checkpoints goes back to one and tells the model", async () => {
t = await tui([])
await t.input.typeText("/checkpoint before the rewrite")
t.input.pressEnter()
expect(await t.until("checkpoint saved: before the rewrite")).toBe(true)
await Bun.write(`${t.cwd}/hello.txt`, "rewritten\n")
await t.input.typeText("/checkpoints")
t.input.pressEnter()
expect(await t.until("Checkpoints")).toBe(true)
t.input.pressEnter()
expect(await t.until("1 file differ")).toBe(true)
expect(t.frame()).toContain("Go back to it")
t.input.pressEnter()
expect(await t.until('back at "before the rewrite" — restored 1 file')).toBe(true)
expect(await Bun.file(`${t.cwd}/hello.txt`).text()).toBe("hello world\n")
expect(t.app.engine.pendingContext.at(-1)).toContain('restored the files to the checkpoint "before the rewrite"')
expect(t.app.snapshots!.checkpoints().map((c) => c.label)).toEqual(['before going back to "before the rewrite"', "before the rewrite"])
})
test("/branch makes and switches; the status bar shows the branch; the list marks the current one", async () => {
t = await tui([])
const sh = (...a: string[]) => Bun.spawnSync(["git", "-C", t!.cwd, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...a])
sh("add", "hello.txt")
sh("commit", "-q", "-m", "first")
await t.input.typeText("/branch feature/greeting")
t.input.pressEnter()
expect(await t.until("made feature/greeting from")).toBe(true)
expect(await t.until("@feature/greeting")).toBe(true)
expect(t.app.engine.pendingContext.at(-1)).toContain("switched the repository to the branch feature/greeting")
await t.input.typeText("/branch")
t.input.pressEnter()
expect(await t.until("Branches")).toBe(true)
expect(t.frame()).toContain("New branch…")
expect(t.frame()).toMatch(/feature\/greeting/)
})
test("a long edit's diff shows short in the transcript — and parses — then whole with ctrl+o", async () => {
t = await tui([
{ chunks: [toolCall(0, "w1", "write", JSON.stringify({ path: "long.txt", content: Array.from({ length: 50 }, (_, i) => `row ${i + 1}`).join("\n") + "\n" }))] },
{ chunks: [delta({ content: "Wrote it." })] },
], { width: 100, height: 60 })
t.app.engine.mode = "edit"
await t.input.typeText("write long.txt")
t.input.pressEnter()
expect(await t.until("Wrote it.")).toBe(true)
expect(t.frame()).not.toContain("Error parsing diff")
expect(t.frame()).toContain("row 1")
expect(t.frame()).toMatch(/… \d+ more lines \(ctrl\+o\)/)
expect(t.frame()).not.toContain("row 50")
t.input.pressKey("o", { ctrl: true })
expect(await t.until("row 50")).toBe(true)
expect(t.frame()).not.toContain("more lines (ctrl+o)")
expect(t.frame()).not.toContain("Error parsing diff")
})
for (const size of [{ width: 100, height: 30 }, { width: 80, height: 24 }])
test(`a long command to approve stays on screen at ${size.width}x${size.height}: its purpose, a few rows, the choices; ctrl+o shows the rest`, async () => {
const command = Array.from({ length: 60 }, (_, i) => `echo step-${i + 1}`).join("\n")
t = await tui([{ chunks: [toolCall(0, "c1", "bash", JSON.stringify({ command, description: "Print every step so we can see where the build stops" }))] }, { chunks: [delta({ content: "done" })] }], size)
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Allow once")).toBe(true)
const rows = t.frame().split("\n")
expect(rows.filter((l) => l.trim()).at(-1)).toContain("fake/coder") // the status bar is still the last row
expect(t.frame()).toContain("Print every step so we can see where the build stops")
expect(t.frame()).toContain("echo step-1")
expect(t.frame()).not.toContain("echo step-60")
expect(t.frame()).toMatch(/… \d+ more lines — ctrl\+o shows all/)
t.input.pressKey("o", { ctrl: true })
await t.settle()
for (let i = 0; i < 12; i++) t.input.pressKey("\u001b[6~") // PageDown
const end = await t.until("echo step-60", 3000)
if (!end) console.error(t.frame())
expect(end).toBe(true)
expect(t.frame()).toContain("Allow once")
expect(t.frame().split("\n").filter((l) => l.trim()).at(-1)).toContain("fake/coder")
await t.input.typeText("y")
expect(await t.until("done")).toBe(true)
}, 20_000)
test("/settings: pick a setting, a value and where it holds; the status bar follows; global is written", async () => {
t = await tui([])
await t.input.typeText("/settings")
t.input.pressEnter()
expect(await t.until("Settings")).toBe(true)
await t.input.typeText("titles")
await t.settle()
t.input.pressEnter()
expect(await t.until("titles — now model")).toBe(true)
t.input.pressArrow("down")
await t.settle()
t.input.pressEnter()
expect(await t.until("— where?")).toBe(true)
t.input.pressArrow("down")
await t.settle()
t.input.pressEnter()
expect(await t.until("titles = prompt in the global config")).toBe(true)
expect(await Bun.file(`${process.env.LEMBAS_HOME}/.config/lembas/config.yaml`).text()).toContain("titles: prompt")
// A number is typed: the first row takes what was typed.
await t.input.typeText("/settings limits.steps")
t.input.pressEnter()
expect(await t.until("limits.steps — now")).toBe(true)
await t.input.typeText("40")
await t.settle()
expect(t.frame()).toContain('set to "40"')
t.input.pressEnter()
expect(await t.until("— where?")).toBe(true)
t.input.pressEnter()
expect(await t.until("limits.steps = 40 for this session")).toBe(true)
expect(t.app.engine.o.maxSteps).toBe(40)
}, 30_000)
test("the agent changes the effort when asked: the approval says so, and the status bar shows it", async () => {
t = await tui([
{ chunks: [toolCall(0, "c1", "settings", '{"action":"set","key":"effort","value":"high","purpose":"You asked me to think harder"}')] },
{ chunks: [delta({ content: "Thinking harder now." })] },
])
await t.input.typeText("think harder from now on")
t.input.pressEnter()
expect(await t.until("Allow once")).toBe(true)
expect(t.frame()).toContain("You asked me to think harder")
await t.input.typeText("y")
expect(await t.until("Thinking harder now.")).toBe(true)
expect(t.app.engine.effort).toBe("high")
expect(t.frame()).toContain("fake/coder · high")
})
test("enter while it works: the message waits above the box, then goes in at the next step, shown where it went", async () => {
t = await tui([
{ gapMs: 400, chunks: [delta({ content: "Looking." }), toolCall(0, "c1", "list", "{}")] },
{ chunks: [delta({ content: "Noted the change." }, "stop")] },
])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Looking.")).toBe(true)
await t.input.typeText("use b instead")
t.input.pressEnter()
expect(await t.until("↳ queued use b instead · at the next step")).toBe(true)
expect(await t.until("Noted the change.")).toBe(true)
await t.settle()
const f = t.frame()
expect(f).toContain("↳ sent while it worked")
expect(f).not.toContain("↳ queued")
expect(f.indexOf("use b instead")).toBeLessThan(f.indexOf("Noted the change."))
expect(JSON.stringify(t.fake.requests[1].messages.at(-1))).toContain("use b instead")
})
test("a command refused while it works keeps its text in the box", async () => {
t = await tui([{ gapMs: 400, chunks: [delta({ content: "Slow" }), delta({ content: " reply." }, "stop")] }])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Slow")).toBe(true)
await t.input.typeText("/undo")
t.input.pressEnter()
expect(await t.until("wait for the reply to finish")).toBe(true)
expect(t.frame()).toMatch(/❯\s*\/undo/)
})
test("esc mid-reply, then ctrl+g: it continues, and the model sees what it had already written", async () => {
t = await tui([
{ gapMs: 300, chunks: [delta({ content: "Step one is done. " }), delta({ content: "Step two" }), delta({ content: " is next." }, "stop")] },
{ chunks: [delta({ content: "Step two is done too." }, "stop")] },
])
await t.input.typeText("do the steps")
t.input.pressEnter()
expect(await t.until("Step one is done.")).toBe(true)
t.input.pressEscape()
expect(await t.until("cancelled")).toBe(true)
await t.settle()
t.input.pressKey("g", { ctrl: true })
expect(await t.until("↻ continue")).toBe(true)
expect(await t.until("Step two is done too.")).toBe(true)
const sent = t.fake.requests[1].messages as { role: string; content: unknown }[]
// What it had said before the stop, then the request to carry on.
expect(sent.at(-2)).toMatchObject({ role: "assistant" })
expect(JSON.stringify(sent.at(-2)!.content)).toContain("Step one is done.")
expect(JSON.stringify(sent.at(-1)!.content)).toContain("Continue from where you stopped")
})
test("/continue with nothing to continue, and while it works, says so", async () => {
t = await tui([{ gapMs: 300, chunks: [delta({ content: "Working" }), delta({ content: " on it." }, "stop")] }])
await t.input.typeText("/continue")
t.input.pressEnter()
expect(await t.until("nothing to continue yet")).toBe(true)
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Working")).toBe(true)
t.input.pressKey("g", { ctrl: true })
expect(await t.until("it is still working")).toBe(true)
})
test("/agents lists the subagents; /agents new writes a starter; /agents <name> <task> hands it to the model", async () => {
t = await tui([{ chunks: [delta({ content: "Handing it over." }, "stop")] }])
await t.input.typeText("/agents")
t.input.pressEnter()
expect(await t.until("explore (builtin)")).toBe(true)
expect(t.frame()).toContain("general (builtin)")
const name = `scout${Date.now() % 100000}`
await t.input.typeText(`/agents new ${name}`)
t.input.pressEnter()
expect(await t.until(`${name}.md`)).toBe(true)
const file = Bun.file(`${process.env.LEMBAS_HOME}/.config/lembas/agents/${name}.md`)
expect(await file.text()).toContain(`You are the ${name} subagent`)
await t.input.typeText("/agents explore where is the config loaded")
t.input.pressEnter()
expect(await t.until("Handing it over.")).toBe(true)
expect(JSON.stringify(t.fake.requests[0].messages.at(-1))).toContain('agent: \\"explore\\"')
})
test("while the server reads a long prompt: the line at the bottom says how far it is, then the reply takes over", async () => {
const pp = (processed: number, ms: number) => ({ choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }], prompt_progress: { total: 140000, cache: 10000, processed, time_ms: ms } })
t = await tui([{ gapMs: 500, chunks: [pp(0, 0), pp(80000, 160000), delta({ content: "Read it all." }, "stop")] }])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("reading the prompt 64% · 90k of 140k · ~1m 40s left")).toBe(true)
expect(await t.until("Read it all.")).toBe(true)
await t.settle()
expect(t.frame()).not.toContain("reading the prompt")
})
test("a mouse wheel that arrives as a burst of ↑ scrolls the transcript; one ↑ still walks history", async () => {
t = await tui([{ chunks: [delta({ content: Array.from({ length: 60 }, (_, i) => `line ${i}`).join("\n\n") }, "stop")] }], { width: 80, height: 24 })
await t.input.typeText("first prompt")
t.input.pressEnter()
expect(await t.until("line 59")).toBe(true)
await t.settle()
const before = t.frame()
// What a terminal not passing the mouse on sends for one notch: three ↑ at once.
for (let i = 0; i < 3; i++) t.input.pressKey("ARROW_UP")
await t.settle()
const after = t.frame()
expect(after).not.toBe(before)
expect(after).not.toContain("❯ first prompt")
// One ↑ on its own: the last prompt comes back, as before.
await Bun.sleep(40)
t.input.pressKey("ARROW_UP")
await t.settle()
expect(t.frame()).toContain("❯ first prompt")
})
test("↑ in an empty box walks the messages waiting to go in, newest first; ↓ walks back", async () => {
t = await tui([{ gapMs: 600, chunks: [delta({ content: "Working" }), delta({ content: " on" }), delta({ content: " it." }, "stop")] }, { chunks: [delta({ content: "ok" }, "stop")] }])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Working")).toBe(true)
for (const m of ["fix alpha", "fix beta"]) {
await t.input.typeText(m)
t.input.pressEnter()
}
expect(await t.until("↳ queued fix beta")).toBe(true)
const press = async (k: "ARROW_UP" | "ARROW_DOWN") => {
await Bun.sleep(30)
t!.input.pressKey(k)
await t!.settle()
}
await press("ARROW_UP")
expect(t.frame()).toContain("❯ fix beta")
expect(t.frame()).not.toContain("↳ queued fix beta")
expect(t.app.engine.queued).toBe(1)
await press("ARROW_UP")
expect(t.frame()).toContain("❯ fix alpha")
expect(t.frame()).toContain("↳ queued fix beta")
await press("ARROW_DOWN")
expect(t.frame()).toContain("❯ fix beta")
await press("ARROW_DOWN")
expect(t.app.engine.queued).toBe(2)
expect(t.frame()).toContain("↳ queued fix alpha")
expect(t.frame()).toContain("↳ queued fix beta")
})
test("ctrl+↑ scrolls the transcript three lines; pgup half a screen", async () => {
t = await tui([{ chunks: [delta({ content: Array.from({ length: 60 }, (_, i) => `line ${i}`).join("\n\n") }, "stop")] }], { width: 80, height: 24 })
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("line 59")).toBe(true)
await t.settle()
const top = () => t!.frame().split("\n").find((l) => /line \d+/.test(l))
const first = top()
t.input.pressKey("ARROW_UP", { ctrl: true })
await t.settle()
expect(top()).not.toBe(first)
expect(t.frame()).not.toContain("❯ go")
})
test("/sessions says how to delete, and ctrl+d deletes the session under the cursor", async () => {
t = await tui([], { width: 100, height: 30 }, "", { store: true })
const store = t.app.store!
const root = t.app.project.root
const said = store.createSession(root, "fake/coder")
store.append(said.id, { role: "user", parts: [{ type: "text", text: "an old question" }] })
store.setTitle(said.id, "An old question")
// An empty one is not offered at all.
store.createSession(root, "fake/coder")
await t.input.typeText("/sessions")
t.input.pressEnter()
expect(await t.until("enter resume · ctrl+d delete")).toBe(true)
expect(t.frame()).toContain("An old question")
expect(t.frame().match(/ses_/g)).toBeNull()
t.input.pressKey("d", { ctrl: true })
expect(await t.until("Delete “An old question”?")).toBe(true)
t.input.pressEnter()
expect(await t.until("no other sessions in this project")).toBe(true)
expect(store.session(said.id)).toBeUndefined()
})
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
import type { DialogSpec } from "../../src/tui/components/dialog.tsx"
import { Dialog } from "../../src/tui/components/dialog.tsx"
import { TuiContext } from "../../src/tui/context.ts"
import { DEFAULT_THEME } from "../../src/tui/theme.ts"
let setup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => setup?.renderer.destroy())
test("dialog: arrow down + enter picks the second option — with a spec that closing clears, as in the app", async () => {
const picked: string[] = []
let closed = 0
const ctx = { theme: () => DEFAULT_THEME, dims: () => ({ width: 60, height: 16 }) } as never
const [spec, setSpec] = createSignal<DialogSpec | undefined>({
title: "Model",
options: [{ label: "a", value: "a", current: true }, { label: "b", value: "b" }],
onSelect: (v) => picked.push(v),
})
setup = await testRender(
() => (
<TuiContext.Provider value={ctx}>
<Show when={spec()}>
<Dialog spec={spec()!} close={() => (closed++, setSpec(undefined))} />
</Show>
</TuiContext.Provider>
),
{ width: 60, height: 16 },
)
await setup.renderOnce()
setup.mockInput.pressArrow("down")
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.renderOnce()
expect(picked).toEqual(["b"])
expect(closed).toBe(1)
})
+64
View File
@@ -0,0 +1,64 @@
// A diff's long lines stay inside their background. Left to fill its box, OpenTUI's diff wrapped
// at a width measured before the transcript's scrollbar took its column, so once the transcript
// outgrew the screen a long line ran one column past its colour.
import { afterEach, expect, test } from "bun:test"
import { delta, toolCall } from "../fake-provider.ts"
import { tui } from "./harness.tsx"
let t: Awaited<ReturnType<typeof tui>> | undefined
afterEach(() => t?.stop())
const LONG = "const created=await new Promise((res,rej)=>{const r=http.request({host:'127.0.0.1',port:PORT,path:'/json/new',method:'PUT',headers:{'Content-Type':'application/json'}},e=>{let b='';e.on('data',d=>b+=d);e.on('end',()=>res(JSON.parse(b)))},rej);r.end('{}');});"
const FILE = Array.from({ length: 27 }, (_, i) => (i % 6 === 3 ? `${LONG} // ${i}` : `const v${i}=${i};`)).join("\n") + "\n"
/** Rows where text reaches past the diff's green, the last column (the scrollbar) aside. */
function overruns(): string[] {
const f = t!.setup.captureSpans()
const bad: string[] = []
f.lines.forEach((l, y) => {
let x = 0
let green = -1
let text = -1
for (const s of l.spans) {
const chars = [...s.text]
for (let i = 0; i < s.width; i++) {
if (s.bg.a > 0 && s.bg.g > s.bg.r + 0.03) green = x + i
if ((chars[i] ?? " ").trim() && x + i < f.cols - 1) text = x + i
}
x += s.width
}
if (green >= 0 && text > green) bad.push(`row ${y}: text to ${text}, green to ${green}`)
})
return bad
}
const greenRows = () => t!.setup.captureSpans().lines.filter((l) => l.spans.some((s) => s.bg.a > 0 && s.bg.g > s.bg.r + 0.03)).length
test("a written file's long lines wrap inside their background — before and after the scrollbar shows up", async () => {
t = await tui(
[
{ chunks: [toolCall(0, "w1", "write", JSON.stringify({ path: "diag.js", content: FILE }))] },
{ gapMs: 150, chunks: [delta({ content: "start\n\n" }), delta({ content: Array.from({ length: 40 }, (_, i) => `para ${i}`).join("\n\n") + "\n\nok" }, "stop")] },
],
{ width: 150, height: 60 },
)
t.app.engine.mode = "edit"
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("start")).toBe(true)
await t.settle()
expect(greenRows()).toBeGreaterThan(20)
expect(overruns()).toEqual([])
// The transcript outgrows the screen: the scrollbar takes a column. Back up to the diff.
expect(await t.until("ok")).toBe(true)
await t.settle()
let seen = 0
for (let i = 0; i < 4; i++) {
t.input.pressKey("\u001b[5~")
await t.settle()
await Bun.sleep(30)
await t.settle()
seen += greenRows()
expect(overruns()).toEqual([])
}
expect(seen).toBeGreaterThan(0)
})
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, expect, test } from "bun:test"
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { testRender } from "@opentui/solid"
import { paths } from "../../src/config/paths.ts"
import { trustOf } from "../../src/project/root.ts"
import { Boot } from "../../src/tui/index.tsx"
import { fakeProvider } from "../fake-provider.ts"
let setup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => setup?.renderer.destroy())
async function boot(cwd: string) {
const fake = fakeProvider([])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models: { m: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\n")
let quit = 0
setup = await testRender(() => <Boot options={{ cwd, store: false }} onQuit={() => quit++} />, { width: 100, height: 30 })
const frame = async () => {
for (let i = 0; i < 3; i++) {
await Bun.sleep(20)
await setup!.renderOnce()
}
return setup!.captureCharFrame()
}
return { frame, quits: () => quit, fake }
}
test("first run: trust → git init → .agent/ created, .gitignore updated, session opens", async () => {
const cwd = mkdtempSync(join(tmpdir(), "ph-first-"))
const b = await boot(cwd)
expect(await b.frame()).toContain("Trust this directory?")
setup!.mockInput.pressEnter()
expect(await b.frame()).toContain("No git repository here. Create one?")
setup!.mockInput.pressEnter()
const f = await b.frame()
expect(f).toContain("trusted this directory")
expect(f).toContain("initialised an empty git repository")
expect(f).toContain("created .agent/")
expect(trustOf(cwd)).toBe("trusted")
expect(existsSync(join(cwd, ".git"))).toBe(true)
expect(existsSync(join(cwd, ".agent", "config.yaml"))).toBe(true)
expect(readFileSync(join(cwd, ".gitignore"), "utf8")).toContain("/.agent/local/")
b.fake.stop()
})
test("first run: read-only skips the project dir; a file named .agent sends it to .lembas", async () => {
const cwd = mkdtempSync(join(tmpdir(), "ph-first-"))
Bun.spawnSync(["git", "init", "-q", cwd])
writeFileSync(join(cwd, ".agent"), "someone else's file")
const b = await boot(cwd)
expect(await b.frame()).toContain("Trust this directory?")
setup!.mockInput.pressArrow("down")
setup!.mockInput.pressEnter()
const f = await b.frame()
expect(f).toContain("opened read-only")
expect(f).toContain(" plan · fake/m")
expect(existsSync(join(cwd, ".lembas"))).toBe(false)
b.fake.stop()
const cwd2 = mkdtempSync(join(tmpdir(), "ph-first-"))
Bun.spawnSync(["git", "init", "-q", cwd2])
writeFileSync(join(cwd2, ".agent"), "someone else's file")
setup!.renderer.destroy()
const b2 = await boot(cwd2)
await b2.frame()
setup!.mockInput.pressEnter()
expect(await b2.frame()).toContain("created .lembas/")
expect(existsSync(join(cwd2, ".lembas", "config.yaml"))).toBe(true)
expect(readFileSync(join(cwd2, ".agent"), "utf8")).toContain("someone else's")
b2.fake.stop()
})
test("a known directory skips the questions", async () => {
const cwd = mkdtempSync(join(tmpdir(), "ph-first-"))
const { setTrust } = await import("../../src/project/root.ts")
setTrust(cwd, "trusted")
const b = await boot(cwd)
expect(await b.frame()).toContain("❯")
b.fake.stop()
})
test("first run: ctrl+c leaves, and nothing is written", async () => {
const cwd = mkdtempSync(join(tmpdir(), "ph-first-"))
const b = await boot(cwd)
expect(await b.frame()).toContain("Trust this directory?")
setup!.mockInput.pressCtrlC()
await b.frame()
expect(b.quits()).toBe(1)
expect(trustOf(cwd)).toBeUndefined()
expect(existsSync(join(cwd, ".agent"))).toBe(false)
b.fake.stop()
})
+49
View File
@@ -0,0 +1,49 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { testRender } from "@opentui/solid"
import { createApp } from "../../src/app.ts"
import { Bus } from "../../src/bus/index.ts"
import { paths } from "../../src/config/paths.ts"
import { Root } from "../../src/tui/app.tsx"
import { createView } from "../../src/tui/state.ts"
import { DEFAULT_THEME } from "../../src/tui/theme.ts"
import { fakeProvider, type Scripted } from "../fake-provider.ts"
/** The whole TUI in OpenTUI's test renderer, against a scripted endpoint. */
export async function tui(script: Scripted[], size = { width: 100, height: 30 }, extraConfig = "", o: { store?: boolean } = {}) {
const fake = fakeProvider(script)
mkdirSync(paths.config, { recursive: true })
writeFileSync(
join(paths.config, "connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n coder: { context: 32768, efforts: [low, high], effort: low }\n other: { context: 8192 }\n`,
{ mode: 0o600 },
)
writeFileSync(join(paths.config, "config.yaml"), `model: fake/coder\n${extraConfig}`)
const cwd = mkdtempSync(join(tmpdir(), "lembas-tui-"))
Bun.spawnSync(["git", "init", "-q", cwd])
writeFileSync(join(cwd, "hello.txt"), "hello world\n")
const bus = new Bus()
const view = createView(bus)
// `store`: a real sessions.db (/sessions); by default none.
const app = createApp({ cwd, asker: view.asker, bus, ...(o.store ? {} : { store: false as const }) })
let quit = 0
const setup = await testRender(() => <Root app={app} view={view} theme={DEFAULT_THEME} onQuit={() => quit++} />, size)
const frame = () => setup.captureCharFrame()
const settle = async (ms = 60) => {
for (let i = 0; i < 4; i++) {
await Bun.sleep(ms / 4)
await setup.renderOnce()
}
}
const until = async (text: string, ms = 5000) => {
const end = Date.now() + ms
while (Date.now() < end) {
await settle(40)
if (frame().includes(text)) return true
}
return false
}
await settle()
return { fake, app, view, setup, frame, settle, until, cwd, quits: () => quit, input: setup.mockInput, stop: () => (fake.stop(), setup.renderer.destroy(), void app.close()) }
}
+49
View File
@@ -0,0 +1,49 @@
// The first run with no model: the setup screen opens on "Log in to a LLeMbas instance"
// with the address field focused — typing goes straight into it — and ctrl+r tries again from there.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { testRender } from "@opentui/solid"
import { paths } from "../../src/config/paths.ts"
import { setTrust } from "../../src/project/root.ts"
import { Boot } from "../../src/tui/index.tsx"
import { fakeProvider } from "../fake-provider.ts"
let setup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => setup?.renderer.destroy())
test("no model yet: the sign-in is first and focused; ctrl+r opens the session once there is one", async () => {
const fake = fakeProvider([])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), "connections: {}\n", { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "mode: manual\n")
const cwd = mkdtempSync(join(tmpdir(), "ph-setup-"))
Bun.spawnSync(["git", "init", "-q", cwd])
setTrust(cwd, "trusted")
mkdirSync(join(cwd, ".agent", "plans"), { recursive: true })
setup = await testRender(() => <Boot options={{ cwd, store: false }} onQuit={() => {}} />, { width: 100, height: 40 })
const frame = async () => {
for (let i = 0; i < 3; i++) {
await Bun.sleep(20)
await setup!.renderOnce()
}
return setup!.captureCharFrame()
}
let f = await frame()
expect(f).toContain("no model to talk to yet")
expect(f).toContain("Log in to a LLeMbas instance")
// Before the file instructions: the screen opens on the sign-in.
expect(f.indexOf("Log in to a LLeMbas instance")).toBeLessThan(f.indexOf("connections.yaml"))
// Focused: what is typed is the address, without pressing l first.
await setup.mockInput.typeText("ai.example")
f = await frame()
expect(f).toContain("Address: ai.example")
// A model added meanwhile: ctrl+r, a letter-free key, opens the session.
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models: { m: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\n")
setup.mockInput.pressKey("r", { ctrl: true })
f = await frame()
expect(f).toContain("fake/m")
fake.stop()
})