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:
355 files changed
+47028
No files matched your search
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user