Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
720e5ee
feat(checklist): add inline task checklist with update-checklist tool
cniska Mar 24, 2026
0c406f6
fix(checklist): render checklist between transcript and input
cniska Mar 24, 2026
25f43f8
refactor(checklist): extract ChatChecklist to own file
cniska Mar 24, 2026
fbd19f0
test(checklist): add TUI rendering tests
cniska Mar 24, 2026
f88c6f0
test(checklist): add TUI rendering tests
cniska Mar 24, 2026
6220378
refactor(checklist): remove empty marker box from checklist
cniska Mar 24, 2026
98cb1cf
refactor(test): use shared dedent in tool-output TUI tests
cniska Mar 24, 2026
4c7445c
fix(checklist): address review issues
cniska Mar 24, 2026
a36ebfe
fix(checklist): address PR review findings
cniska Mar 24, 2026
e968e04
chore(checklist): remove field descriptions from input schema
cniska Mar 24, 2026
cbadfed
refactor(checklist): split into set-checklist and update-checklist tools
cniska Mar 24, 2026
645cc7a
refactor(tools): add meta category, make labelKey optional
cniska Mar 24, 2026
af9e328
refactor(checklist): rename set-checklist to create-checklist
cniska Mar 24, 2026
ab4e133
feat(checklist): handle checklist events in run mode
cniska Mar 24, 2026
5e0a814
refactor(checklist): share formatting between TUI and CLI
cniska Mar 24, 2026
f10fabd
test(checklist): add CLI output test for checklist events
cniska Mar 24, 2026
66fd1e2
test(checklist): add contract unit tests
cniska Mar 24, 2026
337652e
refactor(checklist): move formatChecklist to checklist-format
cniska Mar 24, 2026
4620baa
fix(checklist): address final review findings
cniska Mar 24, 2026
3ba6f8d
refactor(checklist): split marker and label in formatted items
cniska Mar 24, 2026
6ef9f6e
chore(checklist): use literal UTF-8 markers
cniska Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ lifecycle → guard → cache → toolkit → registry

- **guard:** pre-execution safety/redundancy checks and post-execution call recording
- **cache:** per-task reuse layer for read-only and search tool results
- **toolkit:** domain tool definitions with guarded execution (`file-toolkit`, `code-toolkit`, `git-toolkit`, `shell-toolkit`, `web-toolkit`)
- **toolkit:** domain tool definitions with guarded execution (`file-toolkit`, `code-toolkit`, `git-toolkit`, `shell-toolkit`, `web-toolkit`, `checklist-toolkit`)
- **registry:** toolkit registration, permission filtering, and agent-facing tool surface
- **details:** see [Tooling](./tooling.md)

Expand Down
1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Shipped, user-visible capabilities.
- automatic formatting of edited files via detected formatter
- automatic linting of edited files via detected linter
- deterministic verify command execution from detected project configuration
- inline task checklist for multi-step tasks, pinned between transcript and input

## Tools

Expand Down
1 change: 1 addition & 0 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Naming conventions and core terms used across Acolyte code and docs.
| Term | Definition |
|---|---|
| Base Agent Input | Immutable prompt input created during `prepare` and used as the base for each generation attempt |
| Checklist | Inline progress display for multi-step tasks, rendered between transcript and input. The agent defines steps via `create-checklist` and marks progress via `update-checklist` |
| Context Budgeting | Proactive token allocation via tiktoken — system prompt reserved first, remaining space filled by priority (memory → attachments → history → tool payloads) |
| Continuation State | Persisted "Current task" and "Next step" cues carried into later turns |
| Distill | Automatic memory source family that extracts and consolidates knowledge into records (project/user/session scope variants) |
Expand Down
2 changes: 1 addition & 1 deletion docs/tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ lifecycle → guard → cache → toolkit → registry
## Layers

- **guard**: pre-execution checks and post-execution call recording
- **toolkit**: domain tool definitions (`file-toolkit`, `code-toolkit`, `git-toolkit`, `shell-toolkit`, `web-toolkit`)
- **toolkit**: domain tool definitions (`file-toolkit`, `code-toolkit`, `git-toolkit`, `shell-toolkit`, `web-toolkit`, `checklist-toolkit`)
- **registry**: permission filtering and agent-facing tool surface

## Guarded execution
Expand Down
1 change: 1 addition & 0 deletions src/agent-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const agentModes: Record<AgentMode, AgentModeDefinition> = {
"Do not run verify, test, or build commands — the lifecycle handles format, lint, and verify automatically after your edits.",
"Do not signal done until the requested behavior is actually implemented. Updating help text, comments, or tests alone is not completing the task — the functional change must be in place.",
"After the last tool call, use the lifecycle signal format from the base instructions and keep the user-facing outcome to one sentence.",
"For multi-step tasks (3+ distinct steps), use `create-checklist` at the start to define a progress checklist. Use `update-checklist` to mark items as you complete each step.",
],
},
verify: {
Expand Down
12 changes: 11 additions & 1 deletion src/chat-app.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { ChatChecklist } from "./chat-checklist";
import type { ChatRow } from "./chat-contract";
import { isChecklistOutput } from "./chat-contract";
import { ChatHeader } from "./chat-header";
import { ChatInputPanel } from "./chat-input-panel";
import { isHeaderItem } from "./chat-promotion";
Expand All @@ -11,6 +14,12 @@ function ChatApp(props: ChatAppProps) {
const { exit } = useApp();
const state = useChatState(props, exit);

const transcriptRows: ChatRow[] = [];
const checklistRows: ChatRow[] = [];
for (const row of state.rows) {
(isChecklistOutput(row.content) ? checklistRows : transcriptRows).push(row);
}

return (
<Box flexDirection="column">
<Static items={state.promotedRows}>
Expand Down Expand Up @@ -39,13 +48,14 @@ function ChatApp(props: ChatAppProps) {
}}
</Static>
<ChatTranscript
rows={state.rows}
rows={transcriptRows}
pendingState={state.pendingState}
pendingFrame={state.pendingFrame}
pendingStartedAt={state.pendingStartedAt}
queuedMessages={state.queuedMessages}
runningUsage={state.runningUsage}
/>
<ChatChecklist rows={checklistRows} />

<Text> </Text>
<ChatInputPanel
Expand Down
44 changes: 44 additions & 0 deletions src/chat-checklist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from "react";
import type { ChatRow } from "./chat-contract";
import { isChecklistOutput } from "./chat-contract";
import type { ChecklistOutput } from "./checklist-contract";
import { formatChecklist } from "./checklist-format";
import { Box, Text } from "./tui";
import { DEFAULT_COLUMNS } from "./tui/styles";

function renderChecklist(output: ChecklistOutput): React.ReactNode {
const { header, items } = formatChecklist(output);
return (
<>
<Text bold>{header}</Text>
{items.map((item) => (
<React.Fragment key={item.id}>
{"\n"}
<Text dimColor>{` ${item.marker} ${item.label}`}</Text>
</React.Fragment>
))}
</>
);
}

type ChatChecklistProps = {
rows: ChatRow[];
};

export function ChatChecklist({ rows }: ChatChecklistProps): React.ReactNode {
if (rows.length === 0) return null;
const columns = process.stdout.columns ?? DEFAULT_COLUMNS;
const contentWidth = Math.max(24, columns - 2);
return (
<>
{rows.map((row) => (
<React.Fragment key={row.id}>
<Text> </Text>
<Box width={contentWidth}>
{isChecklistOutput(row.content) ? <Text>{renderChecklist(row.content)}</Text> : null}
</Box>
</React.Fragment>
))}
</>
);
}
9 changes: 8 additions & 1 deletion src/chat-contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { checklistOutputSchema } from "./checklist-contract";
import { isoDateTimeSchema } from "./datetime";
import { domainIdSchema } from "./id-contract";
import { createId } from "./short-id";
Expand Down Expand Up @@ -48,7 +49,7 @@ export const commandOutputSchema = z.object({

export type CommandOutput = z.infer<typeof commandOutputSchema>;

const chatRowContentSchema = z.union([z.string(), toolOutputSchema, commandOutputSchema]);
const chatRowContentSchema = z.union([z.string(), toolOutputSchema, commandOutputSchema, checklistOutputSchema]);

export type ChatRowContent = z.infer<typeof chatRowContentSchema>;

Expand All @@ -72,3 +73,9 @@ export function isToolOutput(content: ChatRowContent | undefined): content is To
export function isCommandOutput(content: ChatRowContent | undefined): content is CommandOutput {
return typeof content === "object" && "header" in content;
}

export function isChecklistOutput(
content: ChatRowContent | undefined,
): content is z.infer<typeof checklistOutputSchema> {
return typeof content === "object" && "groupId" in content;
}
29 changes: 27 additions & 2 deletions src/chat-message-handler-stream.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ChatRow, createRow } from "./chat-contract";
import type { ChecklistItem } from "./checklist-contract";
import { LIFECYCLE_ERROR_CODES } from "./error-contract";
import { palette } from "./palette";
import { createId } from "./short-id";
Expand All @@ -14,6 +15,7 @@ export type MessageStreamState = {
errorCode?: string;
error?: { category?: string; [key: string]: unknown };
}) => void;
onChecklist: (entry: { groupId: string; groupTitle: string; items: ChecklistItem[] }) => void;
onProgressError: (error: string) => void;
streamedAssistantText: () => string;
/** Flush remaining content and return IDs of all streaming assistant rows (for replacement by final turn rows). */
Expand All @@ -37,6 +39,9 @@ export function createMessageStreamState(input: {
const toolRowIdByCallId = new Map<string, string>();
const toolOutput = createToolOutputState();

// --- checklist state ---
const checklistRowIdByGroupId = new Map<string, string>();

function cancelFlushTimer(): void {
if (flushTimer) {
clearTimeout(flushTimer);
Expand Down Expand Up @@ -120,6 +125,19 @@ export function createMessageStreamState(input: {
);
},

onChecklist: (entry) => {
const content = { groupId: entry.groupId, groupTitle: entry.groupTitle, items: entry.items };
const existingRowId = checklistRowIdByGroupId.get(entry.groupId);
if (!existingRowId) {
sealAssistantRow();
const rowId = `row_${createId()}`;
checklistRowIdByGroupId.set(entry.groupId, rowId);
input.setRows((current) => [...current, { id: rowId, kind: "task" as const, content }]);
return;
}
input.setRows((current) => current.map((row) => (row.id === existingRowId ? { ...row, content } : row)));
},

onProgressError: (error) => {
input.setRows((current) => {
const last = current[current.length - 1];
Expand All @@ -132,20 +150,27 @@ export function createMessageStreamState(input: {

finalize: () => {
sealAssistantRow();
const checklistIds = new Set(checklistRowIdByGroupId.values());
checklistRowIdByGroupId.clear();
if (checklistIds.size > 0) {
input.setRows((current) => current.filter((row) => !checklistIds.has(row.id)));
}
const ids = [...assistantRowIds];
assistantRowIds.length = 0;
return ids;
},

dispose: () => {
cancelFlushTimer();
const checklistIds = new Set(checklistRowIdByGroupId.values());
checklistRowIdByGroupId.clear();
const idsToRemove = [...assistantRowIds];
if (activeRowId && !idsToRemove.includes(activeRowId)) idsToRemove.push(activeRowId);
activeRowId = null;
activeContent = "";
assistantRowIds.length = 0;
if (idsToRemove.length > 0) {
const removeSet = new Set(idsToRemove);
const removeSet = new Set([...idsToRemove, ...checklistIds]);
if (removeSet.size > 0) {
input.setRows((current) => current.filter((row) => !removeSet.has(row.id)));
}
},
Expand Down
3 changes: 3 additions & 0 deletions src/chat-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export function createMessageHandler(input: CreateMessageHandlerInput): {
case "tool-result":
streamState.onToolResult(event);
break;
case "checklist":
streamState.onChecklist(event);
break;
case "error":
streamState.onProgressError(event.errorMessage);
break;
Expand Down
6 changes: 3 additions & 3 deletions src/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,15 +219,15 @@ export function ChatTranscriptRow({ row, contentWidth, toolContentWidth }: ChatT
<Text>{renderToolOutput(row.content.parts, toolContentWidth)}</Text>
) : isCommandOutput(row.content) ? (
<Text>{renderCommandOutput(row.content)}</Text>
) : row.kind === "assistant" ? (
) : row.kind === "assistant" && typeof row.content === "string" ? (
<Text dimColor={dim} color={textColor}>
{renderAssistantContent(row.content, contentWidth)}
</Text>
) : (
) : typeof row.content === "string" ? (
<Text dimColor={dim} color={textColor}>
{row.content}
</Text>
)}
) : null}
</Box>
</Box>
);
Expand Down
45 changes: 45 additions & 0 deletions src/checklist-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test";
import { checklistMarker, checklistProgress } from "./checklist-contract";

describe("checklistMarker", () => {
test("returns correct markers", () => {
expect(checklistMarker("pending")).toBe("○");
expect(checklistMarker("in_progress")).toBe("◐");
expect(checklistMarker("done")).toBe("●");
expect(checklistMarker("failed")).toBe("◉");
});
});

describe("checklistProgress", () => {
test("counts done items", () => {
expect(
checklistProgress([
{ id: "1", label: "a", status: "done", order: 0 },
{ id: "2", label: "b", status: "in_progress", order: 1 },
{ id: "3", label: "c", status: "pending", order: 2 },
]),
).toEqual({ done: 1, total: 3 });
});

test("handles all done", () => {
expect(
checklistProgress([
{ id: "1", label: "a", status: "done", order: 0 },
{ id: "2", label: "b", status: "done", order: 1 },
]),
).toEqual({ done: 2, total: 2 });
});

test("failed items do not count as done", () => {
expect(
checklistProgress([
{ id: "1", label: "a", status: "done", order: 0 },
{ id: "2", label: "b", status: "failed", order: 1 },
]),
).toEqual({ done: 1, total: 2 });
});

test("handles empty list", () => {
expect(checklistProgress([])).toEqual({ done: 0, total: 0 });
});
});
39 changes: 39 additions & 0 deletions src/checklist-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { z } from "zod";

export const checklistItemStatusSchema = z.enum(["pending", "in_progress", "done", "failed"]);
export type ChecklistItemStatus = z.infer<typeof checklistItemStatusSchema>;

export const checklistItemSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
status: checklistItemStatusSchema,
order: z.number().int().nonnegative(),
});

export type ChecklistItem = z.infer<typeof checklistItemSchema>;

export const checklistOutputSchema = z.object({
groupId: z.string().min(1),
groupTitle: z.string().min(1),
items: z.array(checklistItemSchema),
});

export type ChecklistOutput = z.infer<typeof checklistOutputSchema>;

const STATUS_MARKERS: Record<ChecklistItemStatus, string> = {
pending: "○",
in_progress: "◐",
done: "●",
failed: "◉",
};

export function checklistMarker(status: ChecklistItemStatus): string {
return STATUS_MARKERS[status];
}

export function checklistProgress(items: ChecklistItem[]): { done: number; total: number } {
return {
done: items.filter((item) => item.status === "done").length,
total: items.length,
};
}
32 changes: 32 additions & 0 deletions src/checklist-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { formatChecklist } from "./checklist-format";

describe("formatChecklist", () => {
test("returns header with progress and sorted items", () => {
const result = formatChecklist({
groupId: "g1",
groupTitle: "Build",
items: [
{ id: "s2", label: "test", status: "in_progress", order: 1 },
{ id: "s1", label: "lint", status: "done", order: 0 },
{ id: "s3", label: "deploy", status: "pending", order: 2 },
],
});
expect(result.header).toBe("Build (1/3)");
expect(result.items).toEqual([
{ id: "s1", marker: "●", label: "lint" },
{ id: "s2", marker: "◐", label: "test" },
{ id: "s3", marker: "○", label: "deploy" },
]);
});

test("handles single item", () => {
const result = formatChecklist({
groupId: "g1",
groupTitle: "Quick",
items: [{ id: "s1", label: "do it", status: "pending", order: 0 }],
});
expect(result.header).toBe("Quick (0/1)");
expect(result.items).toEqual([{ id: "s1", marker: "○", label: "do it" }]);
});
});
12 changes: 12 additions & 0 deletions src/checklist-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type ChecklistOutput, checklistMarker, checklistProgress } from "./checklist-contract";

export type FormattedChecklistItem = { id: string; marker: string; label: string };

export function formatChecklist(output: ChecklistOutput): { header: string; items: FormattedChecklistItem[] } {
const sorted = [...output.items].sort((a, b) => a.order - b.order);
const { done, total } = checklistProgress(sorted);
return {
header: `${output.groupTitle} (${done}/${total})`,
items: sorted.map((item) => ({ id: item.id, marker: checklistMarker(item.status), label: item.label })),
};
}
Loading
Loading