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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user