diff --git a/src/agent-loop.ts b/src/agent-loop.ts index ec99858..3e55a7f 100644 --- a/src/agent-loop.ts +++ b/src/agent-loop.ts @@ -118,7 +118,7 @@ export async function runAgentTurn(args: { maxSteps?: number modelName?: string onToolStart?: (toolName: string, input: unknown) => void - onToolResult?: (toolName: string, output: string, isError: boolean) => void + onToolResult?: (toolName: string, output: string, isError: boolean, toolInput?: unknown) => void onAssistantMessage?: (content: string) => void onProgressMessage?: (content: string) => void onAutoCompact?: (result: CompressionResult) => void | Promise @@ -385,7 +385,7 @@ export async function runAgentTurn(args: { if (!result.ok) { toolErrorCount += 1 } - args.onToolResult?.(call.toolName, result.output, !result.ok) + args.onToolResult?.(call.toolName, result.output, !result.ok, call.input) const toolResult = await replaceLargeToolResult({ role: 'tool_result', diff --git a/src/cli-commands.ts b/src/cli-commands.ts index ba48ea0..8900bd9 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -130,6 +130,11 @@ export const SLASH_COMMANDS: SlashCommand[] = [ usage: '/compact', description: 'Compress conversation context to free up context window space.', }, + { + name: '/tasks', + usage: '/tasks', + description: 'Show the current task list.', + }, { name: '/collapse', usage: '/collapse', diff --git a/src/index.ts b/src/index.ts index f9e08c9..5a7d34d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { applyContextCollapseIfNeeded, createContextCollapseState, } from './compact/context-collapse.js' +import { createTaskState } from './task-state.js' import { createContentReplacementState } from './utils/tool-result-storage.js' async function main(): Promise { @@ -65,9 +66,11 @@ async function main(): Promise { runtime = null } + const taskState = createTaskState() const tools = await createDefaultToolRegistry({ cwd, runtime, + taskState, }) const mcpHydration = hydrateMcpTools({ cwd, @@ -128,6 +131,7 @@ async function main(): Promise { permissions, contentReplacementState, contextCollapseState, + taskState, sessionId, alreadySavedCount: 0, resumeTarget: resolvedResumeTarget, diff --git a/src/prompt.ts b/src/prompt.ts index a4f11f6..7799aac 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -41,6 +41,15 @@ export async function buildSystemPrompt( '- Use ask_user when clarification is required; that tool ends the turn and waits for user input.', '- Do not stop after a progress update. After a message, continue the task in the next step.', '- Plain assistant text without is treated as a completed assistant message for this turn.', + 'Task tracking:', + '- You have a task_tracker tool for maintaining a lightweight task list during multi-step work.', + '- Only use task_tracker when the work involves 3 or more distinct steps. Skip it for simple single-step requests.', + '- At the start of a multi-step task, create tasks for each major step using task_tracker with action="create".', + '- Before starting work on a task, mark it in_progress with action="update_status".', + '- After completing a task, mark it completed with action="complete" or action="update_status" status="completed".', + '- Use action="list" to review the current task list at any time.', + '- Keep tasks at a high level (3-10 items). Do not create subtasks or micro-steps.', + '- The task list is shown to the user in the header banner and via /tasks.', ] if (permissionSummary.length > 0) { diff --git a/src/session.ts b/src/session.ts index ade0e9a..9c9c1c7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -10,6 +10,12 @@ import { import { randomUUID } from 'node:crypto' import path from 'node:path' import { MINI_CODE_PROJECTS_DIR } from './config.js' +import { + createTaskState, + fromSnapshot, + type TaskSnapshot, + type TaskState, +} from './task-state.js' import type { ChatMessage } from './types.js' import { createContextCollapseState, @@ -19,7 +25,7 @@ import { const MAX_TITLE_LENGTH = 60 -type EventType = 'system' | 'user' | 'assistant' | 'thinking' | 'progress' | 'tool_call' | 'tool_result' | 'summary' | 'compact_boundary' | 'snip_boundary' | 'context_collapse' | 'rename' +type EventType = 'system' | 'user' | 'assistant' | 'thinking' | 'progress' | 'tool_call' | 'tool_result' | 'summary' | 'compact_boundary' | 'snip_boundary' | 'context_collapse' | 'rename' | 'task_snapshot' export type SnipBoundaryMetadata = { type: 'snip_boundary' @@ -44,6 +50,7 @@ type SessionEvent = { snipMetadata?: SnipBoundaryMetadata contextCollapseSpan?: CollapseSpan title?: string + taskSnapshot?: TaskSnapshot } function projectDirName(cwd: string): string { @@ -312,6 +319,33 @@ export async function appendContextCollapseSpan( await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8') } +export async function appendTaskSnapshot( + cwd: string, + sessionId: string, + snapshot: TaskSnapshot, +): Promise { + const dir = projectDir(cwd) + const filePath = sessionFilePath(cwd, sessionId) + await mkdir(dir, { recursive: true }) + + const lastUuid = await readLastEventUuid(filePath) + const now = new Date().toISOString() + + const event: SessionEvent = { + type: 'task_snapshot', + subtype: 'task_snapshot', + uuid: randomUUID(), + timestamp: now, + sessionId, + cwd, + parentUuid: null, + logicalParentUuid: lastUuid, + taskSnapshot: snapshot, + } + + await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8') +} + export async function appendCompactBoundary( cwd: string, sessionId: string, @@ -437,6 +471,37 @@ export async function loadContextCollapseState( } } +export async function loadTaskState( + cwd: string, + sessionId: string, +): Promise { + try { + const content = await readFile(sessionFilePath(cwd, sessionId), 'utf8') + const lines = content.trim().split('\n').filter(Boolean) + + let lastBoundaryIndex = -1 + for (let i = lines.length - 1; i >= 0; i--) { + const event = parseEvent(lines[i]!) + if (event?.type === 'compact_boundary') { + lastBoundaryIndex = i + break + } + } + + let lastSnapshot: TaskSnapshot | null = null + for (let i = lastBoundaryIndex + 1; i < lines.length; i++) { + const event = parseEvent(lines[i]!) + if (event?.type === 'task_snapshot' && event.taskSnapshot) { + lastSnapshot = event.taskSnapshot + } + } + + return lastSnapshot ? fromSnapshot(lastSnapshot) : null + } catch { + return null + } +} + export async function clearSession( cwd: string, sessionId: string, @@ -698,6 +763,16 @@ export async function loadTranscript( body: `[Snipped earlier context: removed ${event.snipMetadata?.removedCount ?? '?'} messages, freed ~${event.snipMetadata?.tokensFreed ?? '?'} tokens]`, }) break + case 'task_snapshot': + if (event.taskSnapshot) { + const completed = event.taskSnapshot.tasks.filter(t => t.status === 'completed').length + const total = event.taskSnapshot.tasks.length + entries.push({ + kind: 'assistant', + body: `[Tasks: ${completed}/${total} completed]`, + }) + } + break } } diff --git a/src/task-state.ts b/src/task-state.ts new file mode 100644 index 0000000..b7f166a --- /dev/null +++ b/src/task-state.ts @@ -0,0 +1,108 @@ +export type TaskStatus = 'pending' | 'in_progress' | 'completed' + +export type Task = { + id: number + description: string + status: TaskStatus + createdAt: string + updatedAt: string +} + +export type TaskState = { + tasks: Task[] + nextId: number +} + +export type TaskSnapshot = { + tasks: Task[] + nextId: number + timestamp: string +} + +const VALID_TRANSITIONS: Record = { + pending: ['in_progress', 'completed'], + in_progress: ['completed', 'pending'], + completed: [], +} + +const MAX_DISPLAYED_TASKS = 20 + +export function createTaskState(): TaskState { + return { tasks: [], nextId: 1 } +} + +export function addTask(state: TaskState, description: string): Task { + const now = new Date().toISOString() + const task: Task = { + id: state.nextId, + description, + status: 'pending', + createdAt: now, + updatedAt: now, + } + state.tasks.push(task) + state.nextId += 1 + return task +} + +export function transitionTask( + state: TaskState, + id: number, + status: TaskStatus, +): Task { + const task = state.tasks.find(t => t.id === id) + if (!task) { + throw new Error(`Task #${id} not found`) + } + + const allowed = VALID_TRANSITIONS[task.status] + if (!allowed.includes(status)) { + throw new Error( + `Task #${id} is ${task.status} and cannot transition to ${status}`, + ) + } + + task.status = status + task.updatedAt = new Date().toISOString() + return task +} + +export function toSnapshot(state: TaskState): TaskSnapshot { + return { + tasks: state.tasks.map(t => ({ ...t })), + nextId: state.nextId, + timestamp: new Date().toISOString(), + } +} + +export function fromSnapshot(snapshot: TaskSnapshot): TaskState { + return { + tasks: snapshot.tasks.map(t => ({ ...t })), + nextId: snapshot.nextId, + } +} + +export function formatTaskList(state: TaskState): string { + if (state.tasks.length === 0) { + return 'No tasks tracked in this session.' + } + + const completed = state.tasks.filter(t => t.status === 'completed').length + const total = state.tasks.length + const header = `Tasks (${completed}/${total} completed):` + + const displayed = state.tasks.slice(0, MAX_DISPLAYED_TASKS) + const lines = displayed.map(task => { + const icon = + task.status === 'completed' ? 'x' + : task.status === 'in_progress' ? '~' + : ' ' + return ` #${task.id} [${icon}] ${task.description}` + }) + + if (state.tasks.length > MAX_DISPLAYED_TASKS) { + lines.push(` ... and ${state.tasks.length - MAX_DISPLAYED_TASKS} more`) + } + + return [header, ...lines].join('\n') +} diff --git a/src/tools/index.ts b/src/tools/index.ts index e0c5d8b..63aa051 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,7 +2,9 @@ import type { McpServerConfig, RuntimeConfig } from '../config.js' import type { McpServerSummary } from '../mcp.js' import { createMcpBackedTools } from '../mcp.js' import { discoverSkills } from '../skills.js' +import type { TaskState } from '../task-state.js' import { ToolRegistry } from '../tool.js' +import type { ToolDefinition } from '../tool.js' import { askUserTool } from './ask-user.js' import { editFileTool } from './edit-file.js' import { grepFilesTool } from './grep-files.js' @@ -12,6 +14,7 @@ import { modifyFileTool } from './modify-file.js' import { patchFileTool } from './patch-file.js' import { readFileTool } from './read-file.js' import { runCommandTool } from './run-command.js' +import { createTaskTrackerTool } from './task-tracker.js' import { webFetchTool } from './web-fetch.js' import { webSearchTool } from './web-search.js' import { writeFileTool } from './write-file.js' @@ -42,11 +45,12 @@ function buildConnectingMcpSummaries( export async function createDefaultToolRegistry(args: { cwd: string runtime: RuntimeConfig | null + taskState?: TaskState }): Promise { const skills = await discoverSkills(args.cwd) const mcpServers = args.runtime?.mcpServers ?? {} - return new ToolRegistry([ + const tools: ToolDefinition[] = [ askUserTool, listFilesTool, grepFilesTool, @@ -59,7 +63,13 @@ export async function createDefaultToolRegistry(args: { createLoadSkillTool(args.cwd), webFetchTool, webSearchTool, - ], { + ] + + if (args.taskState) { + tools.push(createTaskTrackerTool(args.taskState)) + } + + return new ToolRegistry(tools, { skills, mcpServers: buildConnectingMcpSummaries(mcpServers), }) diff --git a/src/tools/task-tracker.ts b/src/tools/task-tracker.ts new file mode 100644 index 0000000..6f126d5 --- /dev/null +++ b/src/tools/task-tracker.ts @@ -0,0 +1,109 @@ +import { z } from 'zod' +import type { ToolDefinition } from '../tool.js' +import { + type TaskState, + addTask, + transitionTask, + formatTaskList, +} from '../task-state.js' + +type Input = { + action: 'create' | 'update_status' | 'complete' | 'list' + description?: string + id?: number + status?: 'pending' | 'in_progress' | 'completed' +} + +export function createTaskTrackerTool( + taskState: TaskState, +): ToolDefinition { + const schema = z.object({ + action: z.enum(['create', 'update_status', 'complete', 'list']), + description: z.string().optional(), + id: z.number().int().positive().optional(), + status: z.enum(['pending', 'in_progress', 'completed']).optional(), + }) + + return { + name: 'task_tracker', + description: + 'Manage a lightweight task list for tracking multi-step work progress. Actions: create (add a new task), update_status (change task status), complete (mark task done), list (show all tasks).', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['create', 'update_status', 'complete', 'list'], + description: 'The action to perform.', + }, + description: { + type: 'string', + description: 'Task description. Required for action "create".', + }, + id: { + type: 'number', + description: 'Task ID. Required for actions "update_status" and "complete".', + }, + status: { + type: 'string', + enum: ['pending', 'in_progress', 'completed'], + description: 'New status. Required for action "update_status".', + }, + }, + required: ['action'], + }, + schema, + async run(input) { + try { + switch (input.action) { + case 'create': { + if (!input.description) { + return { ok: false, output: 'description is required for action "create".' } + } + const task = addTask(taskState, input.description) + return { + ok: true, + output: `Task #${task.id} created: ${task.description}`, + } + } + case 'update_status': { + if (input.id === undefined) { + return { ok: false, output: 'id is required for action "update_status".' } + } + if (!input.status) { + return { ok: false, output: 'status is required for action "update_status".' } + } + const existing = taskState.tasks.find(t => t.id === input.id) + const oldStatus = existing?.status ?? 'unknown' + const task = transitionTask(taskState, input.id, input.status) + return { + ok: true, + output: `Task #${task.id} status: ${oldStatus} -> ${input.status}`, + } + } + case 'complete': { + if (input.id === undefined) { + return { ok: false, output: 'id is required for action "complete".' } + } + const task = transitionTask(taskState, input.id, 'completed') + return { + ok: true, + output: `Task #${task.id} completed: ${task.description}`, + } + } + case 'list': { + return { + ok: true, + output: formatTaskList(taskState), + } + } + } + } catch (error) { + return { + ok: false, + output: error instanceof Error ? error.message : String(error), + } + } + }, + } +} diff --git a/src/tty-app.ts b/src/tty-app.ts index 1296888..d1c450f 100644 --- a/src/tty-app.ts +++ b/src/tty-app.ts @@ -25,14 +25,22 @@ import { appendCompactBoundary, appendSnipBoundary, appendContextCollapseSpan, + appendTaskSnapshot, loadTranscript, loadContextCollapseState, + loadTaskState, forkSession, cleanupExpiredSessions, listAllProjects, } from './session.js' import type { SessionMeta, ProjectMeta } from './session.js' import { spawn } from 'node:child_process' +import { + createTaskState, + formatTaskList, + toSnapshot, + type TaskState, +} from './task-state.js' import { parseInputChunk, type ParsedInputEvent } from './tui/input-parser.js' import { clearScreen, @@ -87,6 +95,7 @@ type TtyAppArgs = { sessionId: string alreadySavedCount: number resumeTarget?: string | 'picker' + taskState: TaskState } type PendingApproval = { @@ -138,6 +147,7 @@ type TranscriptEntryDraft = | Omit, 'id'> | Omit, 'id'> | Omit, 'id'> + | Omit, 'id'> function formatRelativeTime(timestamp: number): string { const seconds = Math.floor((Date.now() - timestamp) / 1000) @@ -158,6 +168,12 @@ export function keepSelectionAfterMouseRelease( function getSessionStats(args: TtyAppArgs, state: ScreenState) { const mcpStatus = summarizeMcpServers(args.tools.getMcpServers()) + const taskSummary = args.taskState.tasks.length > 0 + ? { + completed: args.taskState.tasks.filter(t => t.status === 'completed').length, + total: args.taskState.tasks.length, + } + : null return { transcriptCount: state.transcript.length, messageCount: args.messages.length, @@ -167,6 +183,7 @@ function getSessionStats(args: TtyAppArgs, state: ScreenState) { mcpConnectingCount: mcpStatus.connecting, mcpErrorCount: mcpStatus.error, contextStats: state.contextStats, + taskSummary, } } @@ -796,6 +813,10 @@ async function resumeSession( args.contextCollapseState = await loadContextCollapseState(args.cwd, sessionId) ?? createContextCollapseState() + const loadedTaskState = await loadTaskState(args.cwd, sessionId) + if (loadedTaskState) { + args.taskState = loadedTaskState + } state.transcriptScrollOffset = 0 } @@ -1070,6 +1091,7 @@ async function handleInput( args.sessionId = crypto.randomUUID().slice(0, 8) args.alreadySavedCount = 0 args.contextCollapseState = createContextCollapseState() + args.taskState = createTaskState() state.transcript = [] args.messages.length = 0 await refreshSystemPrompt(args) @@ -1108,6 +1130,14 @@ async function handleInput( state.historyIndex = state.history.length state.historyDraft = '' + if (input === '/tasks') { + pushTranscriptEntry(state, { + kind: 'assistant', + body: formatTaskList(args.taskState), + }) + return false + } + if (input === '/tools') { pushTranscriptEntry(state, { kind: 'assistant', @@ -1305,7 +1335,7 @@ async function handleInput( state.transcriptScrollOffset = 0 rerender() }, - onToolResult(toolName, output, isError) { + onToolResult(toolName, output, isError, toolInput) { const pending = pendingToolEntries.get(toolName) ?? [] const entryId = pending.shift() pendingToolEntries.set(toolName, pending) @@ -1371,6 +1401,22 @@ async function handleInput( status: isError ? 'error' : 'success', }) } + + if (toolName === 'task_tracker' && !isError) { + const input = (toolInput ?? {}) as Record + const action = input.action as string | undefined + let taskAction: 'created' | 'updated' | 'completed' = 'updated' + if (action === 'create') taskAction = 'created' + else if (action === 'complete') taskAction = 'completed' + pushTranscriptEntry(state, { + kind: 'task_update', + action: taskAction, + taskSummary: output, + }) + state.transcriptScrollOffset = 0 + void appendTaskSnapshot(args.cwd, args.sessionId, toSnapshot(args.taskState)).catch(() => {}) + } + state.activeTool = null state.status = 'Thinking...' rerender() diff --git a/src/tui/chrome.ts b/src/tui/chrome.ts index 74693bf..05dd158 100644 --- a/src/tui/chrome.ts +++ b/src/tui/chrome.ts @@ -276,6 +276,7 @@ export function renderBanner( mcpConnectedCount: number mcpConnectingCount: number mcpErrorCount: number + taskSummary?: { completed: number; total: number } | null contextStats?: { utilization: number warningLevel: 'normal' | 'warning' | 'critical' | 'blocked' @@ -319,6 +320,9 @@ export function renderBanner( ...(session.mcpErrorCount > 0 ? [colorBadge('mcp-err', String(session.mcpErrorCount), BRIGHT_RED)] : []), + ...(session.taskSummary && session.taskSummary.total > 0 + ? [colorBadge('tasks', `${session.taskSummary.completed}/${session.taskSummary.total}`, CYAN)] + : []), ] const metaLine = joinSegmentsWithinWidth(metaBadges, ' ', panelInner) diff --git a/src/tui/transcript.ts b/src/tui/transcript.ts index 55adfa6..a1fa476 100644 --- a/src/tui/transcript.ts +++ b/src/tui/transcript.ts @@ -150,6 +150,14 @@ function renderTranscriptEntry(entry: TranscriptEntry): string { )}` } + if (entry.kind === 'task_update') { + const icon = entry.action === 'created' ? '+' + : entry.action === 'completed' ? '✓' + : '→' + const color = entry.action === 'completed' ? GREEN : YELLOW + return `${DIM}task${RESET} ${color}${icon}${RESET} ${entry.taskSummary}` + } + const status = entry.status === 'running' ? `${YELLOW}running${RESET}` diff --git a/src/tui/types.ts b/src/tui/types.ts index b0d0b46..51b2b37 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -24,3 +24,9 @@ export type TranscriptEntry = collapsedSummary?: string collapsePhase?: 1 | 2 | 3 } + | { + id: number + kind: 'task_update' + action: 'created' | 'updated' | 'completed' + taskSummary: string + } diff --git a/test/task-tracker.test.ts b/test/task-tracker.test.ts new file mode 100644 index 0000000..5f2a8bd --- /dev/null +++ b/test/task-tracker.test.ts @@ -0,0 +1,166 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + createTaskState, + addTask, + transitionTask, + toSnapshot, + fromSnapshot, + formatTaskList, +} from '../src/task-state.js' + +describe('createTaskState', () => { + it('returns empty state with nextId 1', () => { + const state = createTaskState() + assert.deepEqual(state.tasks, []) + assert.equal(state.nextId, 1) + }) +}) + +describe('addTask', () => { + it('creates a pending task with auto-incrementing id', () => { + const state = createTaskState() + const t1 = addTask(state, 'First task') + const t2 = addTask(state, 'Second task') + + assert.equal(t1.id, 1) + assert.equal(t1.description, 'First task') + assert.equal(t1.status, 'pending') + assert.ok(t1.createdAt) + assert.ok(t1.updatedAt) + + assert.equal(t2.id, 2) + assert.equal(state.tasks.length, 2) + assert.equal(state.nextId, 3) + }) +}) + +describe('transitionTask', () => { + it('transitions pending -> in_progress', () => { + const state = createTaskState() + addTask(state, 'Task') + const updated = transitionTask(state, 1, 'in_progress') + assert.equal(updated.status, 'in_progress') + }) + + it('transitions pending -> completed', () => { + const state = createTaskState() + addTask(state, 'Task') + const updated = transitionTask(state, 1, 'completed') + assert.equal(updated.status, 'completed') + }) + + it('transitions in_progress -> completed', () => { + const state = createTaskState() + addTask(state, 'Task') + transitionTask(state, 1, 'in_progress') + const updated = transitionTask(state, 1, 'completed') + assert.equal(updated.status, 'completed') + }) + + it('transitions in_progress -> pending', () => { + const state = createTaskState() + addTask(state, 'Task') + transitionTask(state, 1, 'in_progress') + const updated = transitionTask(state, 1, 'pending') + assert.equal(updated.status, 'pending') + }) + + it('rejects completed -> pending', () => { + const state = createTaskState() + addTask(state, 'Task') + transitionTask(state, 1, 'completed') + assert.throws( + () => transitionTask(state, 1, 'pending'), + /cannot transition/, + ) + }) + + it('rejects completed -> in_progress', () => { + const state = createTaskState() + addTask(state, 'Task') + transitionTask(state, 1, 'completed') + assert.throws( + () => transitionTask(state, 1, 'in_progress'), + /cannot transition/, + ) + }) + + it('throws for unknown task id', () => { + const state = createTaskState() + assert.throws( + () => transitionTask(state, 99, 'completed'), + /not found/, + ) + }) + + it('updates the updatedAt timestamp', () => { + const state = createTaskState() + const task = addTask(state, 'Task') + const before = task.updatedAt + const updated = transitionTask(state, 1, 'in_progress') + assert.ok(updated.updatedAt >= before) + }) +}) + +describe('toSnapshot / fromSnapshot', () => { + it('round-trips state through snapshot', () => { + const state = createTaskState() + addTask(state, 'First') + addTask(state, 'Second') + transitionTask(state, 1, 'completed') + + const snapshot = toSnapshot(state) + assert.ok(snapshot.timestamp) + + const restored = fromSnapshot(snapshot) + assert.equal(restored.nextId, state.nextId) + assert.equal(restored.tasks.length, 2) + assert.equal(restored.tasks[0]!.status, 'completed') + assert.equal(restored.tasks[1]!.status, 'pending') + }) + + it('produces independent copies', () => { + const state = createTaskState() + addTask(state, 'Task') + + const snapshot = toSnapshot(state) + const restored = fromSnapshot(snapshot) + + addTask(state, 'Another') + assert.equal(state.tasks.length, 2) + assert.equal(restored.tasks.length, 1) + }) +}) + +describe('formatTaskList', () => { + it('returns placeholder for empty state', () => { + const state = createTaskState() + assert.equal(formatTaskList(state), 'No tasks tracked in this session.') + }) + + it('formats tasks with correct icons', () => { + const state = createTaskState() + addTask(state, 'Do thing A') + addTask(state, 'Do thing B') + addTask(state, 'Do thing C') + transitionTask(state, 1, 'completed') + transitionTask(state, 2, 'in_progress') + + const output = formatTaskList(state) + assert.ok(output.includes('Tasks (1/3 completed):')) + assert.ok(output.includes('#1 [x] Do thing A')) + assert.ok(output.includes('#2 [~] Do thing B')) + assert.ok(output.includes('#3 [ ] Do thing C')) + }) + + it('truncates at 20 tasks', () => { + const state = createTaskState() + for (let i = 0; i < 25; i++) { + addTask(state, `Task ${i}`) + } + const output = formatTaskList(state) + assert.ok(output.includes('... and 5 more')) + assert.ok(!output.includes('#21')) + }) +})