Harness spec 1.1.0: one plan, prototypes and the library shared; the CLI implements it
The spec: plan_write and plan_update (new, shared, offered in every mode) and plan_submit (now shared) keep one structured plan — findings, objectives, phases of tasks — in place of the todo list, which lives on as a former name of plan_write. The shared system text is one text for both projects, with variables for what differs; the mode texts, a prototypes block and a library block are shared. acp.md: the client hands the device what only it knows (context, library, prototypes), the plan travels as a structure (plan_state), library calls carry the chat's scope, and a client may rewind a session. loop.json says its numbers as numbers. Four conformance areas: plan, prototype, library_scope, toolcall_index. The CLI: the plan is kept per session in the store and drawn above the prompt and on the approval card; the prompt is assembled from the spec's texts with their variables filled; the link reads the instance's instructions, personalization, knowledge scope and prototype offer, sends the plan as a structure and answers rewind; library calls to the instance carry the session as scope; a prototype written in the terminal is saved as a file and named in the transcript; a tool-call fragment without an index continues the last call; the default theme is Moria; dead exports and Hermes leftovers removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
96 files changed
+3259
-527
No files matched your search
@@ -45,7 +45,7 @@ export function FirstRun(p: { project: Project; onDone: (r: FirstRunResult) => v
|
||||
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
|
||||
{theme().branding.agentName} — first time here
|
||||
</text>
|
||||
<text fg={c().text} wrapMode="word">
|
||||
{p.project.root}
|
||||
|
||||
@@ -5,6 +5,16 @@ import { estimateTokens } from "../../provider/tokens.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
import type { Item } from "../state.ts"
|
||||
import { clipDiff } from "../diffclip.ts"
|
||||
import { TOOL_SPECS } from "../../harness.ts"
|
||||
import { resolveCall } from "../../tool/names.ts"
|
||||
import { splitPrototypes } from "../../prototype/fence.ts"
|
||||
|
||||
/** A call the transcript does not draw: a plan change (the panel above the prompt follows it) or a
|
||||
* tool the spec marks hidden — under its former name too (a stored session's `todo`). */
|
||||
const undrawn = (name: string) => {
|
||||
const block = TOOL_SPECS[resolveCall(name, {}).name]?.block
|
||||
return block === "plan" || block === "hidden"
|
||||
}
|
||||
|
||||
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] ?? ""]
|
||||
@@ -14,7 +24,7 @@ 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 (Array.isArray(a.phases)) return String(a.title ?? "").slice(0, 120)
|
||||
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)
|
||||
@@ -38,11 +48,33 @@ function UserMessage(p: { item: Extract<Item, { kind: "user" }> }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** A reply as markdown — except a completed prototype fence, which is saved as a file
|
||||
* (state.ts) and drawn as one line saying so in its place; while it still streams it is the
|
||||
* code block it is so far. */
|
||||
function AssistantMessage(p: { item: Extract<Item, { kind: "assistant" }> }) {
|
||||
const { theme, md } = useTui()
|
||||
const segments = () => (p.item.text.includes("```") || p.item.text.includes("~~~") ? splitPrototypes(p.item.text) : [])
|
||||
const whole = () => !segments().some((s) => s.kind === "prototype")
|
||||
return (
|
||||
<box paddingLeft={2} marginTop={1}>
|
||||
<markdown content={p.item.text.trim()} syntaxStyle={md()} streaming={p.item.streaming} fg={theme().colors.text} conceal />
|
||||
<box paddingLeft={2} marginTop={1} flexDirection="column">
|
||||
<Show when={!whole()} fallback={<markdown content={p.item.text.trim()} syntaxStyle={md()} streaming={p.item.streaming} fg={theme().colors.text} conceal />}>
|
||||
<For each={segments()}>
|
||||
{(seg, i) => {
|
||||
const saved = () => (seg.kind === "prototype" && seg.closed ? p.item.prototypes?.[seg.start] : undefined)
|
||||
const last = () => i() === segments().length - 1
|
||||
return (
|
||||
<Show
|
||||
when={saved()}
|
||||
fallback={<markdown content={(seg.kind === "text" ? seg.text : seg.raw).trim()} syntaxStyle={md()} streaming={p.item.streaming && last()} fg={theme().colors.text} conceal />}
|
||||
>
|
||||
<text fg={theme().colors.muted} wrapMode="none" truncate>
|
||||
{`prototype · ${saved()!.title || saved()!.kind} · saved to ${saved()!.path}`}
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -229,7 +261,7 @@ export function Messages() {
|
||||
<Match when={item.kind === "reasoning"}>
|
||||
<Reasoning item={item as Extract<Item, { kind: "reasoning" }>} />
|
||||
</Match>
|
||||
<Match when={item.kind === "tool"}>
|
||||
<Match when={item.kind === "tool" && !undrawn((item as Extract<Item, { kind: "tool" }>).name)}>
|
||||
<ToolLine item={item as Extract<Item, { kind: "tool" }>} />
|
||||
</Match>
|
||||
<Match when={item.kind === "notice"}>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { open, summary, type TaskStatus } from "../../plan/plan.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
|
||||
const MARK: Record<TaskStatus, string> = { todo: "☐", doing: "▸", done: "☑", dropped: "✗" }
|
||||
|
||||
/** The session's plan, above the prompt while anything on it is still to do: the active phase
|
||||
* and the count, the task being done, what is next, the last two done — at most six lines. */
|
||||
export function PlanPanel() {
|
||||
const { theme, view } = useTui()
|
||||
const c = () => theme().colors
|
||||
const digest = () => (view.state.livePlan ? summary(view.state.livePlan) : undefined)
|
||||
return (
|
||||
<Show when={view.state.livePlan && open(view.state.livePlan)}>
|
||||
<box flexDirection="column" paddingLeft={1}>
|
||||
<text fg={c().label} wrapMode="none" truncate>
|
||||
{digest()?.head}
|
||||
</text>
|
||||
<For each={digest()?.lines ?? []}>
|
||||
{(t) => (
|
||||
<text wrapMode="none" truncate fg={t.status === "doing" ? c().accent : t.status === "done" ? c().dim : c().text}>
|
||||
{" "}
|
||||
{MARK[t.status]} {t.text}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
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 { TASK_MARK, type Plan } from "../../plan/plan.ts"
|
||||
import type { PlanReply, PlanRequest } from "../../tool/plan_submit.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
|
||||
export interface PendingPlan {
|
||||
path: string
|
||||
text: string
|
||||
/** The plan as the card draws it: render()'s lines, each with what it is, so the ids and marks
|
||||
* stay on screen (a markdown box would read `[t1]` as a link and conceal it). */
|
||||
export function planLines(plan: Plan): { kind: "title" | "text" | "phase" | "todo" | "doing" | "done" | "dropped"; text: string }[] {
|
||||
const out: ReturnType<typeof planLines> = [{ kind: "title", text: plan.title }]
|
||||
if (plan.summary) out.push({ kind: "text", text: plan.summary })
|
||||
if (plan.findings.length) out.push({ kind: "text", text: `Findings: ${plan.findings.map((f) => `[${f.id}] ${f.text}`).join("; ")}` })
|
||||
if (plan.objectives.length) out.push({ kind: "text", text: `Objectives: ${plan.objectives.map((o) => `[${o.id}] ${o.text} (${o.status})`).join("; ")}` })
|
||||
for (const ph of plan.phases) {
|
||||
out.push({ kind: "phase", text: `${ph.id} ${ph.title} (${ph.status})` })
|
||||
for (const t of ph.tasks) out.push({ kind: t.status, text: `[${t.id}] ${TASK_MARK[t.status]} ${t.text}${t.note ? ` — ${t.note}` : ""}` })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface PendingPlan extends PlanRequest {
|
||||
resolve: (r: PlanReply) => void
|
||||
}
|
||||
|
||||
@@ -19,7 +32,7 @@ const CHOICES = [
|
||||
|
||||
/** 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 { theme, dims } = useTui()
|
||||
const c = () => theme().colors
|
||||
const [sel, setSel] = createSignal(0)
|
||||
const [typing, setTyping] = createSignal<"revise" | "back">()
|
||||
@@ -54,9 +67,11 @@ export function PlanCard(p: { pending: PendingPlan }) {
|
||||
})
|
||||
|
||||
return (
|
||||
<box border borderStyle="rounded" borderColor={c().info} flexDirection="column" paddingLeft={1} paddingRight={1} title={` plan · ${p.pending.path} `} titleColor={c().info}>
|
||||
<box border borderStyle="rounded" borderColor={c().info} flexDirection="column" paddingLeft={1} paddingRight={1} title={` plan · ${p.pending.plan.title} `} 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 />
|
||||
<box flexDirection="column">
|
||||
<For each={planLines(p.pending.plan)}>{(l) => <text fg={l.kind === "title" ? c().title : l.kind === "phase" ? c().accent : l.kind === "doing" ? c().accent : l.kind === "done" || l.kind === "dropped" ? c().dim : c().text} attributes={l.kind === "title" || l.kind === "phase" ? TextAttributes.BOLD : 0} wrapMode="word">{l.text}</text>}</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<Show
|
||||
when={!typing()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 type { Answer } from "../../tool/ask_user.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
import type { PendingQuestion } from "../state.ts"
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function Setup(p: { error: Error; onRetry: () => void; onQuit: () => void
|
||||
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"}
|
||||
{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. */}
|
||||
|
||||
@@ -16,9 +16,9 @@ export function reading(p: { total: number; done: number; processed: number; ms:
|
||||
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.
|
||||
// The working line: a braille frame, 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)
|
||||
@@ -41,12 +41,10 @@ export function Spinner() {
|
||||
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
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function Welcome() {
|
||||
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>
|
||||
{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>
|
||||
|
||||
Reference in new issue
Block a user