Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 26 additions & 2 deletions nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,23 @@ def _cli_stderr(line: str) -> None:
# No allowed_tools — can_use_tool callback handles permissions.
# External MCP server tools are discovered at connection time,
# so we can't enumerate them upfront.
#
# ``ScheduleWakeup`` is left enabled. Behaviour in Nerve:
# • The CLI's internal scheduler "fires" the wakeup at the
# scheduled time and enqueues the stored prompt as a
# synthetic user message inside the SDK subprocess.
# • Nerve has no background reader between turns — the queued
# wakeup just sits in the SDK buffer.
# • On the next user message, Nerve calls ``client.query()``
# and the buffered wakeup is flushed BEFORE the user's
# input, so the model sees the self-scheduled prompt first.
# • If the user never sends another message before the idle
# timeout reaps the client, the wakeup is lost (durable
# wakeups would survive in ~/.claude/scheduled_tasks.json,
# but the model rarely sets ``durable: true``).
# Net: it's a deferred-prompt, not an autonomous timer. The UI
# renders the call (see ``ScheduleWakeupBlock``) so the user
# knows what the model scheduled.
env=self._build_env(),
cwd=str(self.config.workspace),
mcp_servers=self._build_mcp_servers(session_id),
Expand Down Expand Up @@ -1908,8 +1925,15 @@ async def _image_prompt():
tool_use_id=tool_use_id,
parent_tool_use_id=parent_id,
)
# Track sub-agent lifecycle
if tool_name == "Task" and tool_use_id:
# Track sub-agent lifecycle. Claude Code
# 2.1.x renamed the subagent-spawning
# tool from ``Task`` → ``Agent`` (and
# introduced separate ``TaskCreate``/
# ``TaskUpdate``/etc. tools for in-
# session todo tracking). Match both
# names so old session history still
# opens panels on replay.
if tool_name in ("Task", "Agent") and tool_use_id:
active_subagents[tool_use_id] = asyncio.get_event_loop().time()
await broadcaster.broadcast_subagent_start(
session_id,
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/Chat/BackgroundJobs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ function formatElapsed(startedAt: number): string {
return `${min}m ${sec % 60}s`;
}

/** Icon for task tool type. */
/** Icon for task tool type. Claude Code 2.1.x renamed Task → Agent. */
function toolIcon(tool: string) {
if (tool === 'Task') return Bot;
if (tool === 'Agent' || tool === 'Task') return Bot;
return Terminal;
}

Expand Down
86 changes: 70 additions & 16 deletions web/src/components/Chat/TodoPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,62 @@
import { useEffect, useRef, useState } from 'react';
import { CheckCircle2, Circle, Loader2 } from 'lucide-react';
import type { TodoItem } from '../../stores/chatStore';
import type { TodoItem, CCTask } from '../../stores/chatStore';

export function TodoPanel({ todos }: { todos: TodoItem[] }) {
/**
* Unified row model rendered by the panel. Both legacy TodoWrite items
* and Claude Code 2.1+ tasks collapse into this shape so the panel only
* needs one renderer.
*/
interface PanelRow {
key: string;
label: string;
activeLabel: string;
status: 'pending' | 'in_progress' | 'completed';
/** Numeric task id ("1", "2", ...) — only set for CC tasks; shown as a badge. */
id?: string;
}

function ccTaskToRow(task: CCTask, index: number): PanelRow {
// Skip the placeholder prefix in the badge — show "—" while we wait.
const badge = task.id.startsWith('pending:') ? undefined : task.id;
return {
key: `cc:${task.id || index}`,
label: task.subject,
activeLabel: task.activeForm || task.subject,
status: task.status,
id: badge,
};
}

function todoToRow(todo: TodoItem, index: number): PanelRow {
return {
key: `todo:${index}:${todo.content}`,
label: todo.content,
activeLabel: todo.activeForm || todo.content,
status: todo.status,
};
}

export function TodoPanel({
todos,
ccTasks,
}: {
todos: TodoItem[];
ccTasks?: CCTask[];
}) {
const [visible, setVisible] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevTodosRef = useRef<TodoItem[]>([]);
// Track previous row count so we can re-show the panel when new work appears.
const prevCountRef = useRef<number>(0);

// CC tasks supersede legacy TodoWrite in Claude Code 2.1+, but a session
// mid-migration could have both. Show whichever has rows; if both, show
// CC tasks (the modern source of truth).
const rows: PanelRow[] = ccTasks && ccTasks.length > 0
? ccTasks.map(ccTaskToRow)
: todos.map(todoToRow);

const allDone = todos.length > 0 && todos.every(t => t.status === 'completed');
const allDone = rows.length > 0 && rows.every(r => r.status === 'completed');

// Auto-hide 5s after all items complete
useEffect(() => {
Expand All @@ -27,38 +76,40 @@ export function TodoPanel({ todos }: { todos: TodoItem[] }) {
};
}, [allDone]);

// Reset visibility when todos change from empty
// Reset visibility when row count grows from empty
useEffect(() => {
if (prevTodosRef.current.length === 0 && todos.length > 0) {
if (prevCountRef.current === 0 && rows.length > 0) {
setVisible(true);
}
prevTodosRef.current = todos;
}, [todos]);
prevCountRef.current = rows.length;
}, [rows.length]);

if (todos.length === 0 || !visible) return null;
if (rows.length === 0 || !visible) return null;

const completed = rows.filter(r => r.status === 'completed').length;

return (
<div className={`border-t border-border-subtle bg-bg-sunken shrink-0 transition-all duration-300 ${allDone ? 'opacity-60' : ''}`}>
<div className="max-w-3xl mx-auto px-5 py-2.5">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] font-medium text-text-faint uppercase tracking-wider">Tasks</span>
<span className="text-[10px] text-text-faint">
{todos.filter(t => t.status === 'completed').length}/{todos.length}
{completed}/{rows.length}
</span>
</div>
<div className="space-y-0.5">
{todos.map((todo, i) => (
<TodoRow key={`${i}-${todo.content}`} todo={todo} />
{rows.map(row => (
<TaskRow key={row.key} row={row} />
))}
</div>
</div>
</div>
);
}

function TodoRow({ todo }: { todo: TodoItem }) {
const isCompleted = todo.status === 'completed';
const isActive = todo.status === 'in_progress';
function TaskRow({ row }: { row: PanelRow }) {
const isCompleted = row.status === 'completed';
const isActive = row.status === 'in_progress';

return (
<div className={`todo-row flex items-center gap-2 py-0.5 text-[13px] transition-opacity duration-300 ${isCompleted ? 'opacity-50' : ''}`}>
Expand All @@ -69,8 +120,11 @@ function TodoRow({ todo }: { todo: TodoItem }) {
) : (
<Circle size={14} className="text-text-faint shrink-0" />
)}
{row.id && (
<span className="text-[10px] tabular-nums text-text-faint shrink-0">#{row.id}</span>
)}
<span className={`${isCompleted ? 'line-through text-text-faint' : isActive ? 'text-text' : 'text-text-muted'} transition-colors duration-300`}>
{isActive ? todo.activeForm : todo.content}
{isActive ? row.activeLabel : row.label}
</span>
</div>
);
Expand Down
18 changes: 18 additions & 0 deletions web/src/components/Chat/ToolCallBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { BashToolBlock } from './tools/BashToolBlock';
import { FileToolBlock } from './tools/FileToolBlock';
import { MemoryToolBlock } from './tools/MemoryToolBlock';
import { TaskToolBlock } from './tools/TaskToolBlock';
import { CCTaskToolBlock } from './tools/CCTaskToolBlock';
import { ScheduleWakeupBlock } from './tools/ScheduleWakeupBlock';
import { SourceToolBlock } from './tools/SourceToolBlock';
import { SubagentToolBlock } from './tools/SubagentToolBlock';
import { HoAToolBlock } from './tools/HoAToolBlock';
Expand Down Expand Up @@ -38,8 +40,24 @@ export function ToolCallBlock({ block }: { block: ToolCallBlockData }) {
case 'Read':
case 'Write':
return <FileToolBlock block={block} />;
// Sub-agent spawn: Claude Code 2.1.x renamed "Task" → "Agent". Match both
// so old chat history keeps rendering with the sub-agent card.
case 'Agent':
case 'Task':
return <SubagentToolBlock block={block} />;
// Claude Code 2.1+ task tools (replacement for TodoWrite). Compact card
// in chat; full list shown by the TaskPanel below the message stream.
case 'TaskCreate':
case 'TaskUpdate':
case 'TaskList':
case 'TaskGet':
case 'TaskStop':
case 'TaskOutput':
return <CCTaskToolBlock block={block} />;
// Claude Code 2.1+ self-paced ``/loop`` wakeup. Nerve has no timer
// for it, but we render the call so the user sees what was scheduled.
case 'ScheduleWakeup':
return <ScheduleWakeupBlock block={block} />;
case 'AskUserQuestion':
return <QuestionBlock block={block} />;
case 'ExitPlanMode':
Expand Down
133 changes: 133 additions & 0 deletions web/src/components/Chat/tools/CCTaskToolBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { ChevronRight, ChevronDown, Plus, Pencil, ListTodo, FileText, OctagonX, Radio, Loader2 } from 'lucide-react';
import { useState } from 'react';
import type { ToolCallBlockData } from '../../../types/chat';
import { extractResultText } from '../../../utils/extractResultText';

/**
* Compact card for Claude Code 2.1+ task tools (TaskCreate / TaskUpdate /
* TaskList / TaskGet / TaskStop / TaskOutput). One-line by default; click
* to expand input + result.
*
* The real "what tasks are open" view lives in the TaskPanel below the
* message stream — these cards just acknowledge the call in the chat.
*/
export function CCTaskToolBlock({ block }: { block: ToolCallBlockData }) {
const [expanded, setExpanded] = useState(false);
const isRunning = block.status === 'running';
const input = block.input || {};
const resultText = block.result !== undefined ? extractResultText(block.result) : '';

let Icon = ListTodo;
let label = block.tool;
let summary = '';
let iconColor = 'text-text-muted';

switch (block.tool) {
case 'TaskCreate': {
Icon = Plus;
label = 'Create task';
summary = String(input.subject || '');
iconColor = 'text-hue-blue';
break;
}
case 'TaskUpdate': {
Icon = Pencil;
label = 'Update task';
const id = String(input.taskId || '');
const status = String(input.status || '');
summary = id
? `#${id}${status ? ' → ' + status : ''}`
: status;
iconColor = status === 'completed' ? 'text-hue-green' : 'text-hue-amber';
break;
}
case 'TaskList': {
Icon = ListTodo;
label = 'List tasks';
// Count the rendered "#N [status]" lines; fall back to "" so the
// summary stays empty rather than showing a misleading "0 tasks"
// while the call is still running.
if (resultText) {
if (/^\s*No tasks found\s*$/i.test(resultText)) {
summary = 'no tasks';
} else {
const count = resultText.split('\n').filter(l => /^\s*#\d+\s+\[/.test(l)).length;
summary = count > 0 ? `${count} task${count === 1 ? '' : 's'}` : '';
}
}
iconColor = 'text-text-muted';
break;
}
case 'TaskGet': {
Icon = FileText;
label = 'Read task';
const id = String(input.taskId || '');
summary = id ? `#${id}` : '';
iconColor = 'text-text-muted';
break;
}
case 'TaskStop': {
Icon = OctagonX;
label = 'Stop task';
summary = String(input.taskId || input.task_id || '');
iconColor = 'text-hue-red';
break;
}
case 'TaskOutput': {
Icon = Radio;
label = 'Task output';
summary = String(input.taskId || input.task_id || '');
iconColor = 'text-text-muted';
break;
}
}

return (
<div className="my-1.5 border border-border rounded-lg bg-surface overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full px-3 py-2 text-left cursor-pointer hover:bg-surface-raised transition-colors"
>
{isRunning
? <Loader2 size={14} className="text-accent animate-spin shrink-0" />
: <Icon size={14} className={`shrink-0 ${block.isError ? 'text-hue-red' : iconColor}`} />
}
<span className="text-[13px] font-medium text-text-secondary shrink-0 whitespace-nowrap">{label}</span>
{summary && <span className="text-[12px] text-text-muted truncate">{summary}</span>}
<div className="ml-auto shrink-0">
{expanded ? <ChevronDown size={14} className="text-text-faint" /> : <ChevronRight size={14} className="text-text-faint" />}
</div>
</button>

{expanded && (
<div className="border-t border-border">
{/* Input */}
<div className="px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-text-faint mb-1">Input</div>
<pre className="text-[12px] text-text-muted font-mono whitespace-pre-wrap overflow-x-auto max-h-40 overflow-y-auto bg-bg rounded p-2 border border-border-subtle">
{JSON.stringify(input, null, 2)}
</pre>
</div>

{/* Result */}
{resultText && (
<div className="px-3 py-2 border-t border-border-subtle">
<div className="text-[10px] uppercase tracking-wider text-text-faint mb-1">
{block.isError ? 'Error' : 'Result'}
</div>
<pre className={`text-[12px] whitespace-pre-wrap overflow-x-auto max-h-60 overflow-y-auto bg-bg rounded p-2 border border-border-subtle ${block.isError ? 'text-hue-red' : 'text-text-muted'}`}>
{resultText}
</pre>
</div>
)}

{isRunning && block.result === undefined && (
<div className="px-3 py-3 text-[12px] text-text-dim flex items-center gap-2 border-t border-border-subtle">
<Loader2 size={12} className="animate-spin" /> Running...
</div>
)}
</div>
)}
</div>
);
}
Loading