LLeMbas CLI 1.0.0
ci / check (push) Canceled after 0s

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

+111
View File
@@ -0,0 +1,111 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useTui } from "../context.ts"
export interface Option {
label: string
value: string
description?: string
current?: boolean
}
export interface DialogSpec {
title: string
options: Option[]
onSelect: (value: string) => void
/** Filter as you type (on by default for more than 8 options). */
filter?: boolean
/** What was typed can itself be the answer: a first row `<label> "<typed>"` (implies filter). */
custom?: { label: string; onSubmit: (text: string) => void }
/** More keys on the highlighted row — the Resume picker's ctrl+d deletes the session
* under it. The dialog closes first, as for enter; `when(key)` says whether a key is this one. */
keys?: { when: (key: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean }) => boolean; run: (value: string) => void }[]
/** The footer in place of `↑↓ move · enter choose · esc close`, to name those keys: a
* binding nobody is told about is one nobody finds, which is how delete went unseen in /sessions. */
hint?: string
}
const CUSTOM = "\u0000custom"
/** A modal list: type to filter, ↑↓ to move, enter to choose, esc to close. */
export function Dialog(p: { spec: DialogSpec; close: () => void }) {
const { theme, dims } = useTui()
const c = () => theme().colors
const [query, setQuery] = createSignal("")
const filterable = () => p.spec.custom !== undefined || (p.spec.filter ?? p.spec.options.length > 8)
const shown = createMemo(() => {
const q = query().toLowerCase()
const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options
return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits
})
const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
const [sel, setSel] = createSignal(start)
const visible = () => Math.max(3, Math.min(14, dims().height - 10))
const offset = () => Math.max(0, Math.min(sel() - Math.floor(visible() / 2), shown().length - visible()))
useKeyboard((key) => {
const n = shown().length
const bound = p.spec.keys?.find((k) => k.when(key))
if (bound) {
const o = shown()[sel()]
if (o && o.value !== CUSTOM) {
// Taken before closing, as for enter: closing clears the spec.
const run = bound.run
p.close()
run(o.value)
}
} else if (key.name === "escape") p.close()
else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1))
else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
else if (key.name === "return") {
const o = shown()[sel()]
if (o) {
// Props are live: closing clears the spec, so take the callback first.
const onSelect = p.spec.onSelect
const custom = p.spec.custom
const typed = query().trim()
p.close()
if (o.value === CUSTOM) custom?.onSubmit(typed)
else onSelect(o.value)
}
} else if (filterable() && key.name === "backspace") {
setQuery((q) => q.slice(0, -1))
setSel(0)
} else if (filterable() && key.sequence && key.sequence.length === 1 && key.sequence >= " " && !key.ctrl && !key.meta) {
setQuery((q) => q + key.sequence)
setSel(0)
} else return
key.preventDefault()
key.stopPropagation()
})
const width = () => Math.min(72, dims().width - 4)
return (
<box position="absolute" zIndex={100} left={0} top={0} width={dims().width} height={dims().height} alignItems="center" paddingTop={Math.max(1, Math.floor(dims().height / 6))} backgroundColor={RGBA.fromInts(0, 0, 0, 140)}>
<box width={width()} border borderStyle="rounded" borderColor={c().border} backgroundColor={c().menu} flexDirection="column" paddingLeft={1} paddingRight={1} title={` ${p.spec.title} `} titleColor={c().title}>
<Show when={filterable()}>
<text fg={query() ? c().text : c().dim}>{query() ? `› ${query()}` : "› type to filter"}</text>
</Show>
<For each={shown().slice(offset(), offset() + visible())}>
{(o, i) => {
const active = () => i() + offset() === sel()
return (
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
{o.current ? "● " : " "}
{o.label}
<span style={{ fg: c().muted }}>{o.description ? ` ${o.description}` : ""}</span>
</text>
</box>
)
}}
</For>
<Show when={shown().length === 0}>
<text fg={c().muted}>nothing matches</text>
</Show>
<text fg={c().dim}>{p.spec.hint ?? "↑↓ move · enter choose · esc close"}</text>
</box>
</box>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createSignal, Match, Switch } from "solid-js"
import { gitInit } from "../../git/repo.ts"
import { ignoreLocal, initProjectDir, neverGitInit, setNeverGitInit } from "../../project/init.ts"
import { setTrust, type Project } from "../../project/root.ts"
import { useTui } from "../context.ts"
import { Dialog } from "./dialog.tsx"
export interface FirstRunResult {
quit: boolean
/** What happened, said once the session opens. */
notes: string[]
}
/** The first time in a directory: trust it? create a git repository? then the .agent skeleton. */
export function FirstRun(p: { project: Project; onDone: (r: FirstRunResult) => void }) {
const { theme } = useTui()
const c = () => theme().colors
const notes: string[] = []
const [step, setStep] = createSignal<"trust" | "git">("trust")
let trusted = false
const finish = () => {
if (trusted) {
const made = initProjectDir(p.project)
const name = p.project.dir.slice(p.project.root.length + 1)
if (made.length) notes.push(`created ${name}/ (config.yaml, plans/, commands/, skills/, agents/)`)
if (ignoreLocal(p.project)) notes.push(`added the per-user ${name}/local/ to .gitignore`)
}
p.onDone({ quit: false, notes })
}
// Ctrl+C leaves from here too, as it does everywhere else: nothing has been written yet.
useKeyboard((key) => {
if (key.ctrl && key.name === "c") {
key.preventDefault()
p.onDone({ quit: true, notes })
}
})
const afterTrust = () => {
if (!p.project.gitRoot && !neverGitInit(p.project.root)) setStep("git")
else finish()
}
return (
<box flexDirection="column" width="100%" height="100%" paddingLeft={2} paddingTop={1}>
<text fg={c().title} attributes={TextAttributes.BOLD}>
☤ {theme().branding.agentName} — first time here
</text>
<text fg={c().text} wrapMode="word">
{p.project.root}
</text>
<text fg={c().muted} wrapMode="word">
LLeMbas CLI reads the files here and runs the commands you approve. Trusting the directory also lets its .agent/ apply: its permission rules (they can allow commands without asking, though never past your own global rules or the hardline), a stricter mode, its MCP servers — local ones are programs that start at once — and its commands, agents and skills. Trust only a repository you would run the code of.
</text>
<Switch>
<Match when={step() === "trust"}>
<Dialog
spec={{
title: "Trust this directory?",
options: [
{ label: "Trust it", value: "trusted", description: "its .agent/: rules, MCP servers, commands, agents, skills" },
{ label: "Read-only", value: "readonly", description: "plan mode; its own config is ignored" },
{ label: "Quit", value: "quit" },
],
onSelect: (v) => {
if (v === "quit") return p.onDone({ quit: true, notes })
setTrust(p.project.root, v as "trusted" | "readonly")
trusted = v === "trusted"
notes.push(v === "trusted" ? "trusted this directory" : "opened read-only: plan mode, project config ignored")
afterTrust()
},
}}
close={() => {}}
/>
</Match>
<Match when={step() === "git"}>
<Dialog
spec={{
title: "No git repository here. Create one?",
options: [
{ label: "Create it", value: "init", description: "git init -b main — local only, no remote" },
{ label: "Not now", value: "later", description: "ask again next time" },
{ label: "Never for this directory", value: "never" },
],
onSelect: (v) => {
if (v === "init") {
const r = gitInit(p.project.root)
notes.push(r.message)
if (r.ok) p.project.gitRoot = p.project.root
} else if (v === "never") setNeverGitInit(p.project.root)
finish()
},
}}
close={() => {}}
/>
</Match>
</Switch>
</box>
)
}
+248
View File
@@ -0,0 +1,248 @@
import { TextAttributes } from "@opentui/core"
import { createEffect, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { duration } from "../../duration.ts"
import { estimateTokens } from "../../provider/tokens.ts"
import { useTui } from "../context.ts"
import type { Item } from "../state.ts"
import { clipDiff } from "../diffclip.ts"
const FILETYPES: Record<string, string> = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", md: "markdown", zig: "zig" }
export const filetypeOf = (diff: string) => FILETYPES[/^\+\+\+ \S*\.(\w+)/m.exec(diff)?.[1] ?? ""]
/** The one-line summary of a call before its result says more, e.g. the command or the path. */
function argSummary(args: unknown): string {
if (!args || typeof args !== "object") return ""
const a = args as Record<string, unknown>
if (Array.isArray(a.questions)) return (a.questions as { header?: string }[]).map((q) => q.header).filter(Boolean).join(" · ") || "asking"
if (Array.isArray(a.todos)) return `${a.todos.length} item${a.todos.length === 1 ? "" : "s"}`
if (typeof a.patch === "string") return [...a.patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1]).join(", ")
const v = a.description ?? a.command ?? a.path ?? a.pattern ?? a.id ?? ""
return String(v).split("\n")[0]!.slice(0, 120)
}
function UserMessage(p: { item: Extract<Item, { kind: "user" }> }) {
const { theme } = useTui()
return (
<box border={["left"]} borderColor={theme().colors.userBar} customBorderChars={{ vertical: "┃", topLeft: "", topRight: "", bottomLeft: "", bottomRight: "", horizontal: "", topT: "", bottomT: "", leftT: "", rightT: "", cross: "" } as never} paddingLeft={1} marginTop={1}>
<box flexDirection="column">
<Show when={p.item.mid}>
<text fg={theme().colors.dim} wrapMode="none" truncate>
↳ sent while it worked
</text>
</Show>
<text fg={theme().colors.text} wrapMode="word">
{p.item.text}
</text>
</box>
</box>
)
}
function AssistantMessage(p: { item: Extract<Item, { kind: "assistant" }> }) {
const { theme, md } = useTui()
return (
<box paddingLeft={2} marginTop={1}>
<markdown content={p.item.text.trim()} syntaxStyle={md()} streaming={p.item.streaming} fg={theme().colors.text} conceal />
</box>
)
}
/** Thinking is one line, never the text as it streams (a growing block jumped about as it
* re-laid itself out; what matters is the tools and the answer): "Thinking." "Thinking.."
* "Thinking..." while it thinks, then "Thought for 12.3s · 1,234 tokens" — this thought's time
* and tokens, nothing else (the task's are on the line at the bottom). The tokens are the
* provider's count when the step had this one thought, else an estimate (~). Ctrl+R opens a
* finished thought. */
function Reasoning(p: { item: Extract<Item, { kind: "reasoning" }> }) {
const { theme, thought, view } = useTui()
const [now, setNow] = createSignal(Date.now())
createEffect(() => {
if (!p.item.streaming) return
const t = setInterval(() => setNow(Date.now()), 200)
onCleanup(() => clearInterval(t))
})
const tokens = () => {
const n = p.item.tokens ?? estimateTokens(p.item.text)
return `${p.item.tokens === undefined ? "~" : ""}${n.toLocaleString("en")} token${n === 1 ? "" : "s"}`
}
const dots = () => ".".repeat(1 + (Math.floor((now() - p.item.started) / 400) % 3))
const line = () => (p.item.streaming ? `Thinking${dots()}` : p.item.ms !== undefined ? `Thought for ${duration(p.item.ms)} · ${tokens()}` : `Thought · ${tokens()}`)
return (
<box paddingLeft={2} marginTop={1} flexDirection="column">
<text fg={theme().colors.reasoning} attributes={TextAttributes.ITALIC} wrapMode="none" truncate>
{line()}
</text>
<Show when={view.state.showReasoning && !p.item.streaming}>
<box paddingLeft={2}>
<code filetype="markdown" content={p.item.text.trim()} syntaxStyle={thought()} drawUnstyledText conceal />
</box>
</Show>
</box>
)
}
function ToolLine(p: { item: Extract<Item, { kind: "tool" }> }) {
const { theme, view } = useTui()
const c = () => theme().colors
const emoji = () => (p.item.status === "denied" ? theme().icons.denied : (theme().toolEmoji[p.item.name] ?? (p.item.name.startsWith("mcp__") ? theme().icons.mcp : theme().icons.other)))
const title = () => p.item.title ?? argSummary(p.item.args)
const time = () => (p.item.status === "running" ? "…" : p.item.ms !== undefined ? duration(p.item.ms) : "")
const tail = () => (p.item.output ?? "").trimEnd().split("\n").slice(-12).join("\n")
// Short in the transcript, whole with ctrl+o; always a diff the renderer can parse.
const shown = () => (view.state.expandTools ? { diff: p.item.diff ?? "", hidden: 0 } : clipDiff(p.item.diff ?? "", 24))
return (
<box flexDirection="column" paddingLeft={1}>
<text wrapMode="none" truncate>
<span style={{ fg: c().dim }}>{theme().branding.toolPrefix} </span>
{emoji()}{" "}
<span style={{ fg: p.item.status === "denied" ? c().warn : c().label, attributes: p.item.status === "denied" ? TextAttributes.STRIKETHROUGH : 0 }}>{p.item.name.padEnd(6)}</span>{" "}
<span style={{ fg: p.item.status === "error" ? c().error : p.item.status === "denied" ? c().warn : c().text }}>{title()}</span>{" "}
<span style={{ fg: c().dim }}>{time()}</span>
<Show when={p.item.status === "error"}>
<span style={{ fg: c().error }}> ✗</span>
</Show>
</text>
<For each={p.item.children ?? []}>
{(ch) => (
<text wrapMode="none" truncate>
<span style={{ fg: c().dim }}>{" "}{theme().branding.toolPrefix} </span>
{theme().toolEmoji[ch.name] ?? (ch.name.startsWith("mcp__") ? theme().icons.mcp : theme().icons.other)}{" "}
<span style={{ fg: c().label }}>{ch.name.padEnd(6)}</span>{" "}
<span style={{ fg: ch.isError ? c().error : c().muted }}>{ch.title}</span>
</text>
)}
</For>
<Show when={p.item.diff}>
<box paddingLeft={4} paddingRight={1} flexDirection="column">
<WrappedDiff patch={shown().diff} filetype={filetypeOf(p.item.diff!)} />
<Show when={shown().hidden > 0}>
<text fg={c().muted}>{`… ${shown().hidden} more line${shown().hidden === 1 ? "" : "s"} (ctrl+o)`}</text>
</Show>
</box>
</Show>
<Show when={view.state.expandTools && !p.item.diff && tail()}>
<box paddingLeft={4}>
<text fg={c().muted} wrapMode="char">
{tail()}
</text>
</box>
</Show>
</box>
)
}
/** A unified diff in the transcript, given its width outright. Left to fill its box, OpenTUI's
* diff wrapped long lines at a width measured before the transcript's scrollbar took its column:
* with the scrollbar showing, a long line ran one column past its own background. The box's laid-out width is right, so the diff gets that number. */
function WrappedDiff(p: { patch: string; filetype?: string }) {
const { theme } = useTui()
const c = () => theme().colors
const [width, setWidth] = createSignal<number>()
return (
<box
flexDirection="column"
onSizeChange={function (this: { width: number }) {
const w = this.width
// After this layout pass, not inside it.
if (w > 0 && w !== width()) setTimeout(() => setWidth(w), 0)
}}
>
<diff
width={width()}
diff={p.patch}
view="unified"
filetype={p.filetype}
showLineNumbers
wrapMode="char"
fg={c().text}
addedBg={c().diffAddedBg}
removedBg={c().diffRemovedBg}
contextBg={c().diffContextBg}
addedSignColor={c().diffAddedSign}
removedSignColor={c().diffRemovedSign}
lineNumberFg={c().lineNumber}
lineNumberBg={c().diffContextBg}
/>
</box>
)
}
/** A multi-file diff (/diff), one block per file. */
function DiffBlock(p: { item: Extract<Item, { kind: "diff" }> }) {
const { theme } = useTui()
const c = () => theme().colors
const files = () =>
p.item.patch
.split(/^(?=diff --git )/m)
.filter((f) => f.startsWith("diff --git"))
.map((f) => ({ name: /^diff --git a\/(.+?) b\//.exec(f)?.[1] ?? "?", patch: f }))
return (
<box flexDirection="column" paddingLeft={1} marginTop={1}>
<text fg={c().title}>{p.item.title}</text>
<For each={files()}>
{(f) => (
<box flexDirection="column" paddingLeft={2}>
<text fg={c().label}>{f.name}</text>
<WrappedDiff patch={f.patch} filetype={filetypeOf(f.patch)} />
</box>
)}
</For>
</box>
)
}
function Notice(p: { item: Extract<Item, { kind: "notice" }> }) {
const { theme } = useTui()
return (
<box paddingLeft={1}>
<text fg={p.item.level === "warn" ? theme().colors.warn : theme().colors.muted} wrapMode="word">
{p.item.level === "warn" ? "⚠ " : "· "}
{p.item.text}
</text>
</box>
)
}
function ErrorLine(p: { item: Extract<Item, { kind: "error" }> }) {
const { theme } = useTui()
return (
<box paddingLeft={1} marginTop={1}>
<text fg={theme().colors.error} wrapMode="word">
✗ {p.item.text}
</text>
</box>
)
}
export function Messages() {
const { view } = useTui()
return (
<For each={view.state.items}>
{(item) => (
<Switch>
<Match when={item.kind === "user"}>
<UserMessage item={item as Extract<Item, { kind: "user" }>} />
</Match>
<Match when={item.kind === "assistant"}>
<AssistantMessage item={item as Extract<Item, { kind: "assistant" }>} />
</Match>
<Match when={item.kind === "reasoning"}>
<Reasoning item={item as Extract<Item, { kind: "reasoning" }>} />
</Match>
<Match when={item.kind === "tool"}>
<ToolLine item={item as Extract<Item, { kind: "tool" }>} />
</Match>
<Match when={item.kind === "notice"}>
<Notice item={item as Extract<Item, { kind: "notice" }>} />
</Match>
<Match when={item.kind === "diff"}>
<DiffBlock item={item as Extract<Item, { kind: "diff" }>} />
</Match>
<Match when={item.kind === "error"}>
<ErrorLine item={item as Extract<Item, { kind: "error" }>} />
</Match>
</Switch>
)}
</For>
)
}
+209
View File
@@ -0,0 +1,209 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createSignal, For, Show } from "solid-js"
import type { AskReply } from "../../bus/index.ts"
import { useTui } from "../context.ts"
import type { Pending } from "../state.ts"
import { diffStat } from "../../tool/write.ts"
import { filetypeOf } from "./messages.tsx"
interface Choice {
key: string
label: string
reply: () => AskReply | "reason" | "edit"
}
/** A command as the rows it takes at `width` columns (it is drawn wrapped by character). */
export function rowsOf(text: string, width: number): string[] {
const w = Math.max(1, width)
return text.split("\n").flatMap((l) => (l.length <= w ? [l] : Array.from({ length: Math.ceil(l.length / w) }, (_, i) => l.slice(i * w, (i + 1) * w))))
}
/** Rows of a long command shown before ctrl+o. */
const COLLAPSED = 6
const clipText = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
/** Asked above the prompt: y once · e edit the command, then once · s for the session · p for the
* project · n no · r no, and why.
* First what the model says the call is for; then the command, or the change to files as a diff
* (pgup/pgdn scroll it), so the answer is about the thing itself. The card never grows past the
* screen: a long command is cut to a few rows (ctrl+o: all of it, scrolling), and the body gets
* what the terminal's height leaves after the choices, the prompt and the status bar. */
export function PermissionPrompt(p: { pending: Pending }) {
const { theme, app, dims } = useTui()
const c = () => theme().colors
const always = () => p.pending.decision.always.join(", ")
const choices = (): Choice[] => [
{ key: "y", label: "Allow once", reply: () => ({ kind: "once" }) },
// A command can be corrected before it runs (after LLeMbas): what is written is judged again.
...(p.pending.request.command !== undefined ? [{ key: "e", label: "Edit, then allow", reply: () => "edit" as const }] : []),
...(always() ? [{ key: "s", label: "Always this session", reply: () => ({ kind: "session" }) as AskReply }] : []),
...(always() && app.trusted ? [{ key: "p", label: "Always in this project", reply: () => ({ kind: "project" }) as AskReply }] : []),
{ key: "n", label: "Deny", reply: () => ({ kind: "deny" }) },
{ key: "r", label: "Deny, and say why", reply: () => "reason" as const },
]
const [sel, setSel] = createSignal(0)
const [reasoning, setReasoning] = createSignal(false)
const [editing, setEditing] = createSignal(false)
const [whole, setWhole] = createSignal(false)
let input: { value: string } | undefined
let box: ScrollBoxRenderable | undefined
const diff = () => p.pending.preview?.diff
const stat = () => {
const d = diff()
if (!d) return ""
const { added, removed } = diffStat(d)
return `+${added} −${removed}`
}
const choose = (ch: Choice) => {
const r = ch.reply()
if (r === "reason") return setReasoning(true)
if (r === "edit") return setEditing(true)
p.pending.resolve(r)
}
useKeyboard((key) => {
if (reasoning() || editing()) {
if (key.name === "escape") {
setReasoning(false)
setEditing(false)
key.preventDefault()
}
return
}
const list = choices()
if (key.name === "left" || key.name === "h" || (key.name === "tab" && key.shift)) setSel((i) => (i + list.length - 1) % list.length)
else if (key.name === "right" || key.name === "l" || key.name === "tab") setSel((i) => (i + 1) % list.length)
else if (key.name === "return") choose(list[sel()]!)
else if (key.name === "escape") p.pending.resolve({ kind: "deny" })
else if (key.ctrl && key.name === "o") {
setWhole((v) => !v)
box?.scrollTo(0)
} else if (key.name === "pageup") box?.scrollBy(-Math.max(1, Math.floor(box.height / 2)))
else if (key.name === "pagedown") box?.scrollBy(Math.max(1, Math.floor(box.height / 2)))
else {
const hit = list.find((ch) => ch.key === key.name)
if (!hit) return
choose(hit)
}
key.preventDefault()
})
const what = () => p.pending.request.command ?? p.pending.request.patterns.join(", ")
// Inside the border, the padding and the scrollbar.
const width = () => Math.max(10, dims().width - 5)
const purpose = () => (p.pending.purpose ? rowsOf(p.pending.purpose, width()).slice(0, 3) : [])
const failRows = () => (p.pending.preview?.error ? rowsOf(`this will fail: ${p.pending.preview.error}`, width()).length : 0)
// Rows the body may take: the screen, less the prompt and status bar (4), the border (2), the
// reason and the choices (2 each at most), two rows of the conversation, purpose and failure.
const budget = () => Math.max(3, dims().height - 12 - purpose().length - failRows())
const rows = () => rowsOf(what(), width())
const cut = () => !whole() && rows().length > Math.min(COLLAPSED, budget())
const shown = () => (cut() ? rows().slice(0, Math.min(COLLAPSED, budget()) - 1).join("\n") : what())
// A scrollbox takes the height it is given, so it is given what its content needs, up to the
// budget: the command's rows, the "more lines" row, and the diff's (wrapped beside its gutter; file headers and
// @@ lines are not drawn).
const bodyHeight = () => {
const d = diff()
const diffRows = d ? rowsOf(d.split("\n").filter((l) => !/^(Index:|={5,}|--- |\+\+\+ |@@)/.test(l)).join("\n").replace(/\n$/, ""), width() - 8).length : 0
const commandRows = cut() ? Math.min(COLLAPSED, budget()) : rows().length
return Math.max(1, Math.min(budget(), commandRows + diffRows))
}
const hint = () =>
[cut() ? `… ${rows().length - Math.min(COLLAPSED, budget()) + 1} more lines — ctrl+o shows all` : whole() && rows().length > COLLAPSED ? "ctrl+o shortens it" : "", diff() || whole() ? "pgup/pgdn scroll" : ""]
.filter(Boolean)
.join(" · ")
return (
<box border borderStyle="rounded" borderColor={c().warn} paddingLeft={1} paddingRight={1} flexDirection="column" flexShrink={0} title={` ${p.pending.tool} `} titleColor={c().warn}>
<Show when={purpose().length}>
<text fg={c().accent} wrapMode="none">
{purpose().join("\n")}
</text>
</Show>
<scrollbox ref={(r: ScrollBoxRenderable) => (box = r)} height={bodyHeight()} scrollX={false}>
<text fg={c().text} wrapMode="char">
{shown()}
<Show when={stat()}>
<span style={{ fg: c().muted }}> {stat()}</span>
</Show>
</text>
<Show when={cut()}>
<text fg={c().muted}>{hint().split(" · ")[0]}</text>
</Show>
<Show when={diff()}>
<diff
diff={diff()!}
view="unified"
filetype={filetypeOf(diff()!)}
showLineNumbers
wrapMode="char"
fg={c().text}
addedBg={c().diffAddedBg}
removedBg={c().diffRemovedBg}
contextBg={c().diffContextBg}
addedSignColor={c().diffAddedSign}
removedSignColor={c().diffRemovedSign}
lineNumberFg={c().lineNumber}
lineNumberBg={c().diffContextBg}
/>
</Show>
</scrollbox>
<Show when={p.pending.preview?.error}>
<text fg={c().error} wrapMode="word">
{`this will fail: ${p.pending.preview!.error}`}
</text>
</Show>
<text fg={c().muted} wrapMode="word">
{clipText(p.pending.decision.reason, 160)}
{always() ? ` · always would allow: ${clipText(always(), 80)}` : ""}
{!cut() && hint() ? ` · ${hint()}` : ""}
</text>
<Show when={editing()}>
<box flexDirection="row">
<text fg={c().label}>run › </text>
<input
focused
flexGrow={1}
value={p.pending.request.command ?? ""}
textColor={c().text}
placeholder="the command as it should run (enter to allow it, esc to go back)"
onSubmit={(v: unknown) => {
const line = String(typeof v === "string" ? v : "").trim()
if (line) p.pending.resolve({ kind: "once", command: line })
}}
/>
</box>
</Show>
<Show when={reasoning()}>
<box flexDirection="row">
<text fg={c().label}>why? </text>
<input
focused
flexGrow={1}
ref={(r) => (input = r as never)}
textColor={c().text}
placeholder="tell the model what to do instead (enter to send, esc to go back)"
onSubmit={(v: unknown) => p.pending.resolve({ kind: "deny", feedback: String(typeof v === "string" ? v : (input?.value ?? "")).trim() || undefined })}
/>
</box>
</Show>
<Show when={!reasoning() && !editing()}>
<box flexDirection="row" flexWrap="wrap" gap={1}>
<For each={choices()}>
{(ch, i) => (
<text
fg={i() === sel() ? c().panel : c().text}
bg={i() === sel() ? c().accent : undefined}
attributes={i() === sel() ? TextAttributes.BOLD : 0}
>
{` ${ch.key} ${ch.label} `}
</text>
)}
</For>
</box>
</Show>
</box>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createSignal, For, Show } from "solid-js"
import type { PlanReply } from "../../tool/plan_exit.ts"
import { useTui } from "../context.ts"
export interface PendingPlan {
path: string
text: string
resolve: (r: PlanReply) => void
}
const CHOICES = [
{ key: "a", label: "Approve — edit mode", reply: { kind: "approve", mode: "edit" } },
{ key: "m", label: "Approve — manual mode", reply: { kind: "approve", mode: "manual" } },
{ key: "r", label: "Revise…", reply: "revise" },
{ key: "?", label: "Ask back…", reply: "back" },
] as const
/** plan_submit's card: the plan itself (scroll with pgup/pgdn), then a | m | r | ?; esc keeps planning. */
export function PlanCard(p: { pending: PendingPlan }) {
const { theme, md, dims } = useTui()
const c = () => theme().colors
const [sel, setSel] = createSignal(0)
const [typing, setTyping] = createSignal<"revise" | "back">()
let box: ScrollBoxRenderable | undefined
const choose = (i: number) => {
const ch = CHOICES[i]!
if (ch.reply === "revise" || ch.reply === "back") return setTyping(ch.reply)
p.pending.resolve(ch.reply as PlanReply)
}
useKeyboard((key) => {
if (typing()) {
if (key.name === "escape") {
setTyping(undefined)
key.preventDefault()
}
return
}
if (key.name === "left" || (key.name === "tab" && key.shift)) setSel((i) => (i + CHOICES.length - 1) % CHOICES.length)
else if (key.name === "right" || key.name === "tab") setSel((i) => (i + 1) % CHOICES.length)
else if (key.name === "return") choose(sel())
else if (key.name === "escape") p.pending.resolve({ kind: "dismissed" })
else if (key.name === "pageup") box?.scrollBy(-5)
else if (key.name === "pagedown") box?.scrollBy(5)
else {
const i = CHOICES.findIndex((ch) => ch.key === key.name || ch.key === key.sequence)
if (i < 0) return
choose(i)
}
key.preventDefault()
})
return (
<box border borderStyle="rounded" borderColor={c().info} flexDirection="column" paddingLeft={1} paddingRight={1} title={` plan · ${p.pending.path} `} titleColor={c().info}>
<scrollbox ref={(r: ScrollBoxRenderable) => (box = r)} maxHeight={Math.max(6, Math.floor(dims().height / 2))} scrollX={false}>
<markdown content={p.pending.text} syntaxStyle={md()} fg={c().text} conceal />
</scrollbox>
<Show
when={!typing()}
fallback={
<box flexDirection="row">
<text fg={c().label}>{typing() === "revise" ? "what should change? " : "your question: "}</text>
<input
focused
flexGrow={1}
textColor={c().text}
placeholder="enter to send, esc to go back"
onSubmit={(v: unknown) => {
const t = String(v ?? "").trim()
if (t) p.pending.resolve(typing() === "revise" ? { kind: "revise", feedback: t } : { kind: "back", text: t })
}}
/>
</box>
}
>
<box flexDirection="row" flexWrap="wrap" gap={1}>
<For each={CHOICES}>
{(ch, i) => (
<text fg={i() === sel() ? c().panel : c().text} bg={i() === sel() ? c().info : undefined} attributes={i() === sel() ? TextAttributes.BOLD : 0}>
{` ${ch.key} ${ch.label} `}
</text>
)}
</For>
</box>
<text fg={c().dim}>pgup/pgdn scroll · esc keep planning</text>
</Show>
</box>
)
}
+294
View File
@@ -0,0 +1,294 @@
import type { PasteEvent, TextareaRenderable } from "@opentui/core"
import { existsSync, mkdirSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../../config/paths.ts"
import { clipboardImage, isImage } from "../../project/image.ts"
import { escapeMention } from "../../project/attach.ts"
import fuzzysort from "fuzzysort"
import { createSignal, For, Show } from "solid-js"
import type { FileIndex } from "../../project/files.ts"
import { useTui } from "../context.ts"
import { History } from "../history.ts"
export interface PaletteEntry {
name: string
args?: string
description: string
/** "global"/"project": custom commands from markdown files; "skill": a skill; "mcp": an MCP prompt. */
source?: "builtin" | "global" | "project" | "skill" | "mcp"
}
interface Popup {
kind: "command" | "file"
items: { label: string; detail: string; value: string }[]
/** Where the replaced token starts in the text. */
from: number
}
/** The input: enter sends; shift+enter / alt+enter / ctrl+j add a line; ↑↓ on the edge lines walk
* the messages waiting to go in (newest first), then history. A leading "/" lists commands and
* "@" lists files: ↑↓ choose, tab or enter take it, esc hides the list. */
export function Prompt(p: {
focused: () => boolean
busy: () => boolean
commands: () => PaletteEntry[]
files?: FileIndex
/** false: refused (busy, unknown command) — the text stays in the box. */
onSubmit: (text: string) => boolean | void
ref?: (r: TextareaRenderable) => void
/** The messages waiting to go in: how many, take one out, put it back. */
queue?: { count(): number; take(index: number): string | undefined; putBack(index: number, text: string): void }
/** A burst of ↑/↓ that was a mouse wheel: scroll the transcript `lines` up (-1) or down (1). */
onWheel?: (dir: -1 | 1, lines: number) => void
}) {
const { theme } = useTui()
const c = () => theme().colors
const history = new History()
const [popup, setPopup] = createSignal<Popup>()
const [sel, setSel] = createSignal(0)
let dismissedAt: string | undefined
let area: TextareaRenderable | undefined
// A waiting message taken into the box with ↑: where it was in the queue, and its text as taken.
let pulled: { index: number; text: string } | undefined
// A terminal that does not pass the mouse on (tmux with its mouse off, some remote terminals)
// turns a wheel notch into a burst of ↑ or ↓ — several within a millisecond or two. A key, even
// held down, repeats 25 ms apart or more. A burst scrolls the transcript, and what its first
// arrow did to the box is undone (otherwise the wheel walks the prompt history).
const BURST_MS = 12
let burst: { dir: "up" | "down"; at: number; undo?: { text: string; cursor: number; history: ReturnType<History["mark"]>; pulled: typeof pulled; queue?: () => void } } | undefined
/** ↑/↓ on an edge line: the waiting messages first (↑ newest to oldest), then history. */
const walk = (dir: "up" | "down", t: string): string | undefined => {
const q = p.queue
if (q && pulled && t === pulled.text) {
// Put back the one shown, and show its neighbour — or leave the queue for history / an empty box.
q.putBack(pulled.index, pulled.text)
const next = pulled.index + (dir === "up" ? -1 : 1)
if (next >= 0 && next < q.count()) {
const text = q.take(next)!
pulled = { index: next, text }
return text
}
pulled = undefined
return dir === "up" ? (history.move(-1, "") ?? "") : ""
}
if (q && dir === "up" && !t.trim() && q.count() > 0) {
const index = q.count() - 1
const text = q.take(index)!
pulled = { index, text }
return text
}
return history.move(dir === "up" ? -1 : 1, t)
}
const compute = () => {
if (!area) return setPopup(undefined)
const text = area.plainText
if (text === dismissedAt) return setPopup(undefined)
dismissedAt = undefined
const before = text.slice(0, area.cursorOffset)
if (/^\/[\w:-]*$/.test(text)) {
const q = text.slice(1)
const all = p.commands()
const hits = q ? fuzzysort.go(q, all, { key: "name", limit: 8 }).map((h) => h.obj) : all.slice(0, 8)
setPopup(
hits.length
? { kind: "command", from: 0, items: hits.map((cmd) => ({ label: `/${cmd.name}`, detail: `${cmd.args ? cmd.args + " " : ""}${cmd.description}${cmd.source && cmd.source !== "builtin" ? ` (${cmd.source})` : ""}`, value: cmd.name })) }
: undefined,
)
} else {
const m = p.files ? /(?:^|\s)@([^\s]*)$/.exec(before) : null
if (m) {
const hits = p.files!.search(m[1]!)
setPopup(hits.length ? { kind: "file", from: before.length - m[1]!.length, items: hits.map((f) => ({ label: f, detail: "", value: f })) } : undefined)
} else setPopup(undefined)
}
setSel(0)
}
/** Take the highlighted entry. For a command, enter runs it and tab leaves room for arguments;
* returns true when the prompt should be sent. */
const accept = (run: boolean): boolean => {
const pop = popup()
const item = pop?.items[sel()]
if (!area || !pop || !item) return false
if (pop.kind === "command") {
const cmd = p.commands().find((x) => x.name === item.value)
area.setText(`/${item.value}${!run && cmd?.args ? " " : ""}`)
area.gotoBufferEnd()
setPopup(undefined)
return run
}
const text = area.plainText
const cursor = area.cursorOffset
// Escaped as the expander reads it back: a space or a # in a name.
const insert = escapeMention(item.value) + (item.value.endsWith("/") ? "" : " ")
area.setText(text.slice(0, pop.from) + insert + text.slice(cursor))
area.cursorOffset = pop.from + insert.length
// A directory keeps the list open, one level down.
queueMicrotask(compute)
return false
}
/** An image on the clipboard becomes an @reference to a saved copy. */
const pasteClipboardImage = (): boolean => {
const dir = join(paths.state, "clipboard")
mkdirSync(dir, { recursive: true })
const saved = clipboardImage(join(dir, `paste-${Date.now()}.png`))
if (!saved || !area) return false
area.insertText(`@${saved} `)
return true
}
/** A pasted path to an image (dragged from a file manager: quoted, escaped or file://) → @path. */
const imagePath = (text: string): string | undefined => {
let t = text.trim()
if (t.includes("\n")) return undefined
t = t.replace(/^file:\/\//, "").replace(/^['"](.*)['"]$/, "$1").replace(/\\ /g, " ")
try {
t = decodeURIComponent(t)
} catch {}
return isImage(t) && existsSync(t) ? t : undefined
}
const onPaste = (e: PasteEvent) => {
if (!area) return
const text = new TextDecoder().decode(e.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
// Some terminals hand over an image-only clipboard as an empty bracketed paste.
if (!text.trim()) {
e.preventDefault()
pasteClipboardImage()
return
}
const img = imagePath(text)
e.preventDefault()
area.insertText(img ? `@${img.includes(" ") ? img.replace(/ /g, "\\ ") : img} ` : text)
}
/** What undoes one ↑/↓ step through the queue: the message now in the box goes back, and the one
* that was in the box before (if it came from the queue) is taken out again. */
const queueRestorer = (countBefore: number, pulledBefore: typeof pulled) => () => {
const q = p.queue!
if (pulled && q.count() < countBefore + (pulledBefore ? 1 : 0)) q.putBack(pulled.index, pulled.text)
if (pulledBefore) q.take(pulledBefore.index)
}
const submit = () => {
if (!area) return
const value = area.plainText
if (!value.trim()) return
history.add(value)
pulled = undefined
area.setText("")
setPopup(undefined)
// Cleared first: a command may put text of its own there (/undo puts the prompt back). A
// refusal puts what was typed back, unless something else has been put there.
if (p.onSubmit(value) === false && !area.plainText) {
area.setText(value)
area.gotoBufferEnd()
}
}
return (
<box flexDirection="column" flexShrink={0}>
<Show when={popup()}>
<box flexDirection="column" paddingLeft={3} backgroundColor={c().menu}>
<For each={popup()!.items}>
{(it, i) => (
<text wrapMode="none" truncate bg={i() === sel() ? c().menuCurrent : undefined}>
<span style={{ fg: i() === sel() ? c().title : c().label }}>{it.label}</span>
<span style={{ fg: c().muted }}>{it.detail ? ` ${it.detail}` : ""}</span>
</text>
)}
</For>
</box>
</Show>
<box flexDirection="row" border={["top"]} borderColor={p.busy() ? c().dim : c().border}>
<text fg={p.busy() ? c().dim : c().accent}>{theme().branding.promptSymbol} </text>
<textarea
ref={(r: TextareaRenderable) => {
area = r
p.ref?.(r)
}}
focused={p.focused()}
flexGrow={1}
minHeight={1}
maxHeight={8}
wrapMode="word"
textColor={c().prompt}
focusedTextColor={c().prompt}
cursorColor={c().accent}
placeholder={p.busy() ? "working… (enter adds a message to it; esc stops)" : "Ask · / commands · @ files · !shell"}
placeholderColor={c().dim}
keyBindings={[
{ name: "return", action: "submit" },
{ name: "return", shift: true, action: "newline" },
{ name: "return", meta: true, action: "newline" },
{ name: "j", ctrl: true, action: "newline" },
]}
onContentChange={compute}
onCursorChange={compute}
onSubmit={submit}
onPaste={onPaste}
onKeyDown={(e) => {
if (!area) return
if (e.ctrl && e.name === "v" && pasteClipboardImage()) return e.preventDefault()
const pop = popup()
if (pop) {
const n = pop.items.length
if (e.name === "up" || e.name === "down") {
setSel((i) => (i + (e.name === "up" ? n - 1 : 1)) % n)
return e.preventDefault()
}
if (e.name === "tab" || (e.name === "return" && !e.shift && !e.meta)) {
const send = accept(e.name === "return")
e.preventDefault()
if (send) submit()
return
}
if (e.name === "escape") {
dismissedAt = area.plainText
setPopup(undefined)
return e.preventDefault()
}
}
if (e.name !== "up" && e.name !== "down") return
const t = area.plainText
const now = performance.now()
if (burst && burst.dir === e.name && now - burst.at < BURST_MS && p.onWheel) {
// The second arrow of a burst: it was the wheel. Undo the first, scroll for both.
const u = burst.undo
let lines = 1
if (u) {
u.queue?.()
pulled = u.pulled
history.restore(u.history)
area.setText(u.text)
area.cursorOffset = u.cursor
burst.undo = undefined
lines = 2
}
burst.at = now
p.onWheel(e.name === "up" ? -1 : 1, lines)
return e.preventDefault()
}
const before = { text: t, cursor: area.cursorOffset, history: history.mark(), pulled: pulled && { ...pulled } }
burst = { dir: e.name, at: now, undo: before }
const atFirst = !t.slice(0, area.cursorOffset).includes("\n")
const atLast = !t.slice(area.cursorOffset).includes("\n")
if ((e.name === "up" && atFirst) || (e.name === "down" && atLast)) {
const countBefore = p.queue?.count()
const next = walk(e.name, t)
// Undoing a step through the queue: put the queue back as it was.
if (p.queue && countBefore !== undefined) burst.undo!.queue = queueRestorer(countBefore, before.pulled)
if (next === undefined) return
area.setText(next)
area.gotoBufferEnd()
e.preventDefault()
}
}}
/>
</box>
</box>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createSignal, For, Show } from "solid-js"
import type { Answer } from "../../tool/question.ts"
import { useTui } from "../context.ts"
import type { PendingQuestion } from "../state.ts"
const SOMETHING_ELSE = "Something else…"
const ASK_BACK = "Ask back…"
/** The question tool's card: one question at a time; ↑↓ move, enter/1–9 pick, space toggles when
* several may apply, the last two rows take your own words or a question back; esc skips all. */
export function QuestionCard(p: { pending: PendingQuestion }) {
const { theme } = useTui()
const c = () => theme().colors
const qs = () => p.pending.req.questions
const [qi, setQi] = createSignal(0)
const [sel, setSel] = createSignal(0)
const [picked, setPicked] = createSignal<Set<number>>(new Set())
const [typing, setTyping] = createSignal<"custom" | "back" | undefined>()
const answers: Answer[] = []
const q = () => qs()[qi()]!
const rows = () => [...q().options.map((o) => o.label), SOMETHING_ELSE, ASK_BACK]
const answer = (a: Answer) => {
answers[qi()] = a
if (qi() + 1 < qs().length) {
setQi(qi() + 1)
setSel(0)
setPicked(new Set<number>())
setTyping(undefined)
} else p.pending.resolve({ answers })
}
const choose = (i: number) => {
const n = q().options.length
if (i === n) return setTyping("custom")
if (i === n + 1) return setTyping("back")
if (q().multiple) {
const set = new Set(picked())
if (set.size === 0) set.add(i)
answer({ kind: "options", labels: [...set].sort((a, b) => a - b).map((k) => q().options[k]!.label) })
} else answer({ kind: "options", labels: [q().options[i]!.label] })
}
useKeyboard((key) => {
if (typing()) {
if (key.name === "escape") {
setTyping(undefined)
key.preventDefault()
}
return
}
const n = rows().length
if (key.name === "up") setSel((i) => (i + n - 1) % n)
else if (key.name === "down" || key.name === "tab") setSel((i) => (i + 1) % n)
else if (key.name === "space" && q().multiple && sel() < q().options.length) {
const set = new Set(picked())
set.has(sel()) ? set.delete(sel()) : set.add(sel())
setPicked(set)
} else if (key.name === "return") choose(sel())
else if (key.name === "escape") p.pending.resolve({ dismissed: true })
else if (/^[1-9]$/.test(key.name ?? "") && Number(key.name) <= q().options.length) choose(Number(key.name) - 1)
else return
key.preventDefault()
})
return (
<box border borderStyle="rounded" borderColor={c().info} paddingLeft={1} paddingRight={1} flexDirection="column" title={` ${q().header}${qs().length > 1 ? ` · ${qi() + 1}/${qs().length}` : ""} `} titleColor={c().info}>
<Show when={p.pending.req.why && qi() === 0}>
<text fg={c().muted} wrapMode="word">
{p.pending.req.why}
</text>
</Show>
<text fg={c().text} attributes={TextAttributes.BOLD} wrapMode="word">
{q().question}
</text>
<For each={rows()}>
{(label, i) => {
const opt = () => q().options[i()]
const active = () => i() === sel()
const mark = () => (q().multiple && opt() ? (picked().has(i()) ? "[x] " : "[ ] ") : "")
return (
<box flexDirection="column" backgroundColor={active() ? c().menuCurrent : undefined}>
<text wrapMode="word" fg={active() ? c().title : opt() ? c().text : c().muted}>
{active() ? "› " : " "}
{opt() ? `${i() + 1}. ` : " "}
{mark()}
{label}
<span style={{ fg: c().ok }}>{opt()?.recommended ? " (Recommended)" : ""}</span>
</text>
<Show when={opt()?.description}>
<text fg={c().muted} wrapMode="word">
{" "}
{opt()!.description}
</text>
</Show>
</box>
)
}}
</For>
<Show when={typing()}>
<box flexDirection="row">
<text fg={c().label}>{typing() === "back" ? "your question › " : "your answer › "}</text>
<input
focused
flexGrow={1}
textColor={c().text}
placeholder={typing() === "back" ? "the model answers it, then asks again" : "type, enter to send, esc to go back"}
onSubmit={(v: unknown) => {
const text = String(v ?? "").trim()
if (text) answer(typing() === "back" ? { kind: "back", text } : { kind: "custom", text })
}}
/>
</box>
</Show>
<text fg={c().dim}>↑↓ move · enter or 1–9 choose{q().multiple ? " · space toggles" : ""} · esc skip</text>
</box>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { join } from "node:path"
import { createSignal, Show } from "solid-js"
import { paths } from "../../config/paths.ts"
import { ModelError } from "../../provider/index.ts"
import { useTui } from "../context.ts"
const EXAMPLE = `connections:
local: # any name; llama.cpp, LM Studio, vLLM, Ollama's /v1…
dialect: openai-chat
base_url: http://127.0.0.1:8080/v1
models:
qwen3: { context: 131072 }`
/** Before there is a session: no model to talk to (a fresh install), or a config file that cannot
* be read. Says what to do and where; r tries again once the files are fixed, q or ctrl+c leaves.
* Not an exception: thrown with the terminal still the TUI's, mouse reporting would stay on, and
* every move of the mouse would type escape codes into the shell afterwards. */
export function Setup(p: { error: Error; onRetry: () => void; onQuit: () => void }) {
const { theme } = useTui()
const c = () => theme().colors
const noModel = () => p.error instanceof ModelError
// `l`: sign in to a LLeMbas instance, which writes the connection and the model this screen is
// asking for — so a fresh install needs no file edited at all. A line typed here, then the code
// to approve on the instance; on success the session starts as if `r` had been pressed.
// A fresh install opens on the sign-in, its address field focused: for most people the
// instance is where the models are, and the file below is the other way.
const [typing, setTyping] = createSignal(noModel())
const [address, setAddress] = createSignal("")
const [status, setStatus] = createSignal("")
const [busy, setBusy] = createSignal(false)
const signIn = async () => {
const target = address().trim()
if (!target) return
setBusy(true)
setStatus(`Looking for LLeMbas at ${target}…`)
try {
const { codeInstructions, login } = await import("../../lembas/login.ts")
const r = await login(target, { onCode: (start, d) => setStatus(codeInstructions(start, d)) })
setStatus(`Signed in: ${r.models.length} model${r.models.length === 1 ? "" : "s"} as ${r.connection}/…${r.instanceDefault ? `, starting on ${r.connection}/${r.instanceDefault}` : ""}.`)
p.onRetry()
} catch (e) {
setStatus((e as Error).message)
setBusy(false)
}
}
useKeyboard((key) => {
key.preventDefault()
if (key.ctrl && key.name === "c") return p.onQuit()
// Ctrl+R tries again from anywhere — typing an address included, where r is a letter.
if (key.ctrl && key.name === "r") return p.onRetry()
if (typing()) {
if (busy()) return
if (key.name === "escape") return void (setTyping(false), setStatus(""))
if (key.name === "return" || key.name === "enter") return void signIn()
if (key.name === "backspace") return void setAddress((a) => a.slice(0, -1))
if (key.sequence && key.sequence.length === 1 && key.sequence >= " " && !key.ctrl && !key.meta) setAddress((a) => a + key.sequence)
return
}
if (key.name === "l" && noModel()) return void setTyping(true)
if (key.name === "r") p.onRetry()
else if (key.name === "q" || key.name === "escape") p.onQuit()
})
return (
<box flexDirection="column" width="100%" height="100%" paddingLeft={2} paddingTop={1} paddingRight={2} gap={1}>
<text fg={c().title} attributes={TextAttributes.BOLD}>
☤ {theme().branding.agentName} — {noModel() ? "no model to talk to yet" : "the configuration has a mistake"}
</text>
{/* "No model configured" is what the rest of the screen explains; anything else (a model
that does not exist, a file that does not parse) is shown as it is. */}
<Show when={!p.error.message.startsWith("No model configured")}>
<text fg={noModel() ? c().text : c().error} wrapMode="word">
{p.error.message}
</text>
</Show>
{/* The sign-in first: the screen opens on it, the address field focused. */}
<Show when={noModel()}>
<Show
when={typing()}
fallback={
<text fg={c().muted} wrapMode="word">
{"Sign in to a LLeMbas instance and use its models: press l (or, from a shell, lembas login <address>)."}
</text>
}
>
<box flexDirection="column">
<text fg={c().title} attributes={TextAttributes.BOLD}>
Log in to a LLeMbas instance
</text>
<text fg={c().text}>Address: {address()}▏</text>
</box>
</Show>
<Show when={status()}>
<text fg={c().text} wrapMode="word">
{status()}
</text>
</Show>
<text fg={c().muted} wrapMode="word">
{`Or, without an instance — nothing is predefined: add the server you use to ${join(paths.config, "connections.yaml")}, for example`}
</text>
<box paddingLeft={2}>
<text fg={c().text}>{EXAMPLE}</text>
</box>
<text fg={c().muted} wrapMode="word">
{`and pick the model in ${join(paths.config, "config.yaml")}:`}
</text>
<box paddingLeft={2}>
<text fg={c().text}>model: local/qwen3</text>
</box>
<text fg={c().muted} wrapMode="word">
A key is never pasted in: api_key: "{"{env:NAME}"}" or "{"{file:~/path}"}". https://llembas.eu/docs/ has the other dialects (Anthropic, OpenAI, Gemini, Ollama).
</text>
</Show>
<text fg={c().muted} wrapMode="word">
From a shell, lembas config check reads the files and says what is wrong, and lembas models lists the models it found.
</text>
<text fg={c().label}>{typing() ? "enter sign in · esc back · ctrl+r try again · ctrl+c quit" : `${noModel() ? "l log in · " : ""}r try again · q quit`}</text>
</box>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { createSignal, For, onCleanup } from "solid-js"
import { duration } from "../../duration.ts"
import { useTui } from "../context.ts"
import { fmtTokens } from "./status.tsx"
/** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's
* return_progress). The time left is guessed from the reading so far, once there is enough of it;
* a long prompt reads slower towards its end, so it is a floor more than a promise. */
export function reading(p: { total: number; done: number; processed: number; ms: number }): string {
const pct = Math.floor((p.done / p.total) * 100)
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
if (p.processed >= 2048 && p.ms >= 2000 && p.done < p.total) {
const left = ((p.total - p.done) / p.processed) * p.ms
parts.push(`~${duration(left, true)} left`)
}
return parts.join(" · ")
}
// Hermes' kawaii spinner: a braille frame, a face that changes every few seconds, what is going on
// (a verb while it thinks), and the whole task's time and output tokens — from the prompt until
// the last reply of the task, subagents included.
export function Spinner() {
const { theme, view } = useTui()
const [tick, setTick] = createSignal(0)
const t = setInterval(() => setTick((n) => n + 1), 100)
onCleanup(() => clearInterval(t))
const sp = () => theme().spinner
const pick = (list: string[], every: number) => list[Math.floor(tick() / every) % list.length] ?? ""
const elapsed = () => (tick(), view.state.busySince ? duration(Date.now() - view.state.busySince, true) : "0s")
const tokens = () => {
tick()
const { n, estimated } = view.taskTokens()
return `${estimated ? "~" : ""}${fmtTokens(n)} token${n === 1 ? "" : "s"}`
}
const what = () => {
const s = view.state
if (s.preparing) return `preparing ${s.preparing}`
if (s.phase === "waiting" && s.progress) return reading(s.progress)
if (s.phase === "thinking") return pick(sp().verbs, 30)
if (s.phase === "tools") return "running tools"
if (s.phase === "writing") return "writing"
return "waiting for the model"
}
const face = () => (view.state.phase === "thinking" ? pick(sp().thinking, 30) : pick(sp().waiting, 30))
return (
<box paddingLeft={1} height={1}>
<text wrapMode="none" truncate>
<span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span>
<span style={{ fg: theme().colors.label }}>{face()} </span>
<span style={{ fg: theme().colors.muted }}>{what()}… </span>
<span style={{ fg: theme().colors.dim }}>
{elapsed()} · {tokens()} · esc to stop
</span>
</text>
</box>
)
}
/** Messages sent while the agent works and not given to it yet — one line each, above the input,
* until the next step takes them (or the task ends, in queue mode). */
export function Queued() {
const { theme, view } = useTui()
const when = () => (view.state.queued.mode === "queue" ? "after this task" : "at the next step")
return (
<For each={view.state.queued.texts}>
{(text) => (
<box paddingLeft={1} height={1}>
<text wrapMode="none" truncate>
<span style={{ fg: theme().colors.accent }}>↳ queued </span>
<span style={{ fg: theme().colors.text }}>{text.split("\n")[0]}</span>
<span style={{ fg: theme().colors.dim }}> · {when()}</span>
</text>
</box>
)}
</For>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { basename } from "node:path"
import { Show } from "solid-js"
import { useTui } from "../context.ts"
const MODE_LABEL = { manual: "manual", edit: "edit", auto: "AUTO", plan: "plan" } as const
export function fmtTokens(n: number): string {
return n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
}
/** One line: mode · model · effort on the left; context use and where on the right — or, for a
* moment, a flash (a copy) in their place. */
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; credits?: () => string }) {
const { theme, view, app } = useTui()
const c = () => theme().colors
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
const ctx = () => {
const u = view.state.usage
if (!u) return { text: "ctx —", color: c().statusDim }
const used = view.state.used ?? u.input + u.output
const total = view.state.context
if (!total) return { text: `ctx ${fmtTokens(used)}${u.estimated ? "~" : ""}`, color: c().statusText }
const pct = Math.round((used / total) * 100)
const color = pct >= 90 ? c().statusCritical : pct >= 75 ? c().statusBad : pct >= 50 ? c().statusWarn : c().statusGood
return { text: `ctx ${fmtTokens(used)}${u.estimated ? "~" : ""}/${fmtTokens(total)} ${pct}%`, color }
}
return (
<box height={1} flexDirection="row" justifyContent="space-between" backgroundColor={c().panel} paddingLeft={1} paddingRight={1}>
<text wrapMode="none" truncate>
<span style={{ fg: modeColor() }}>{MODE_LABEL[p.mode() as keyof typeof MODE_LABEL] ?? p.mode()}</span>
<span style={{ fg: c().statusDim }}> · </span>
<span style={{ fg: c().statusStrong }}>{p.model()}</span>
<span style={{ fg: c().statusDim }}>{p.effort() ? ` · ${p.effort()}` : ""}</span>
<span style={{ fg: p.voice?.().startsWith("●") ? c().statusCritical : c().statusText }}>{p.voice?.() ? ` · ${p.voice()}` : ""}</span>
<span style={{ fg: c().info }}>{p.remote?.() ? ` · ${p.remote()}` : ""}</span>
</text>
<Show
when={p.flash?.()}
fallback={
<text wrapMode="none">
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
<span style={{ fg: c().statusText }}>{p.credits?.() ? `${p.credits()} · ` : ""}</span>
<span style={{ fg: ctx().color }}>{ctx().text}</span>
<span style={{ fg: c().statusDim }}> · {basename(app.project.root)}{p.branch ? `@${p.branch}` : ""}</span>
</text>
}
>
<text wrapMode="none" fg={c().statusGood}>
{p.flash!()}
</text>
</Show>
</box>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { For, Show } from "solid-js"
import { useTui } from "../context.ts"
const MARK = { pending: "☐", in_progress: "▸", completed: "☑", cancelled: "✗" } as const
/** The todo tool's list, above the prompt while anything on it is still open. */
export function Todos() {
const { theme, view } = useTui()
const c = () => theme().colors
const open = () => view.state.todos.some((t) => t.status === "pending" || t.status === "in_progress")
const done = () => view.state.todos.filter((t) => t.status === "completed").length
// The item in progress, what is left, then the latest done — at most six lines.
const shown = () => {
const t = view.state.todos
const order = [...t.filter((x) => x.status === "in_progress"), ...t.filter((x) => x.status === "pending"), ...t.filter((x) => x.status === "completed").slice(-2)]
return order.slice(0, 6)
}
return (
<Show when={open()}>
<box flexDirection="column" paddingLeft={1}>
<text fg={c().label}>
todo {done()}/{view.state.todos.length}
</text>
<For each={shown()}>
{(t) => (
<text wrapMode="none" truncate fg={t.status === "in_progress" ? c().accent : t.status === "completed" ? c().dim : c().text}>
{" "}
{MARK[t.status]} {t.content}
</text>
)}
</For>
</box>
</Show>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { TextAttributes } from "@opentui/core"
import { Show } from "solid-js"
import { VERSION } from "../../version.ts"
import { useTui } from "../context.ts"
export function Welcome() {
const { theme, app } = useTui()
const c = () => theme().colors
return (
<box border borderStyle="rounded" borderColor={c().border} flexDirection="column" paddingLeft={1} paddingRight={1} marginTop={1} flexShrink={0}>
<text fg={c().title} attributes={TextAttributes.BOLD}>
☤ {theme().branding.agentName} <span style={{ fg: c().dim, attributes: 0 }}>{VERSION}</span>
</text>
<text fg={c().text} wrapMode="none" truncate>
<span style={{ fg: c().label }}>model </span>
{app.engine.model.ref}
</text>
<text fg={c().text} wrapMode="none" truncate>
<span style={{ fg: c().label }}>project </span>
{app.project.root}
<span style={{ fg: c().dim }}>{app.project.gitRoot ? " (git)" : " (no git)"}</span>
</text>
<Show when={!app.trusted}>
<text fg={c().warn} wrapMode="word">
This project is not trusted: its .agent config, commands and servers are ignored. /trust to change that.
</text>
</Show>
<text fg={c().dim} wrapMode="word">
enter send · shift+enter newline · shift+tab mode · /help
</text>
</box>
)
}