diff --git a/apps/app/src/components/ui/log-viewer.tsx b/apps/app/src/components/ui/log-viewer.tsx
index 169c626f..a926e2d9 100644
--- a/apps/app/src/components/ui/log-viewer.tsx
+++ b/apps/app/src/components/ui/log-viewer.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useMemo } from "react";
+import { useState, useMemo, useEffect, useRef } from "react";
import {
ChevronDown,
ChevronRight,
@@ -14,13 +14,26 @@ import {
Info,
FileOutput,
Brain,
+ Eye,
+ Pencil,
+ Terminal,
+ Search,
+ ListTodo,
+ Layers,
+ X,
+ Filter,
+ Circle,
+ Play,
+ Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
parseLogOutput,
getLogTypeColors,
+ shouldCollapseByDefault,
type LogEntry,
type LogEntryType,
+ type ToolCategory,
} from "@/lib/log-parser";
interface LogViewerProps {
@@ -53,6 +66,160 @@ const getLogIcon = (type: LogEntryType) => {
}
};
+/**
+ * Returns a tool-specific icon based on the tool category
+ */
+const getToolCategoryIcon = (category: ToolCategory | undefined) => {
+ switch (category) {
+ case "read":
+ return ;
+ case "edit":
+ return ;
+ case "write":
+ return ;
+ case "bash":
+ return ;
+ case "search":
+ return ;
+ case "todo":
+ return ;
+ case "task":
+ return ;
+ default:
+ return ;
+ }
+};
+
+/**
+ * Returns color classes for a tool category
+ */
+const getToolCategoryColor = (category: ToolCategory | undefined): string => {
+ switch (category) {
+ case "read":
+ return "text-blue-400 bg-blue-500/10 border-blue-500/30";
+ case "edit":
+ return "text-amber-400 bg-amber-500/10 border-amber-500/30";
+ case "write":
+ return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30";
+ case "bash":
+ return "text-purple-400 bg-purple-500/10 border-purple-500/30";
+ case "search":
+ return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30";
+ case "todo":
+ return "text-green-400 bg-green-500/10 border-green-500/30";
+ case "task":
+ return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30";
+ default:
+ return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30";
+ }
+};
+
+/**
+ * Interface for parsed todo items from TodoWrite tool
+ */
+interface TodoItem {
+ content: string;
+ status: "pending" | "in_progress" | "completed";
+ activeForm?: string;
+}
+
+/**
+ * Parses TodoWrite JSON content and extracts todo items
+ */
+function parseTodoContent(content: string): TodoItem[] | null {
+ try {
+ // Find the JSON object in the content
+ const jsonMatch = content.match(/\{[\s\S]*"todos"[\s\S]*\}/);
+ if (!jsonMatch) return null;
+
+ const parsed = JSON.parse(jsonMatch[0]) as { todos?: TodoItem[] };
+ if (!parsed.todos || !Array.isArray(parsed.todos)) return null;
+
+ return parsed.todos;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Renders a list of todo items with status icons and colors
+ */
+function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
+ const getStatusIcon = (status: TodoItem["status"]) => {
+ switch (status) {
+ case "completed":
+ return ;
+ case "in_progress":
+ return ;
+ case "pending":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusColor = (status: TodoItem["status"]) => {
+ switch (status) {
+ case "completed":
+ return "text-emerald-300 line-through opacity-70";
+ case "in_progress":
+ return "text-amber-300";
+ case "pending":
+ return "text-zinc-400";
+ default:
+ return "text-zinc-400";
+ }
+ };
+
+ const getStatusBadge = (status: TodoItem["status"]) => {
+ switch (status) {
+ case "completed":
+ return (
+
+ Done
+
+ );
+ case "in_progress":
+ return (
+
+ In Progress
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {todos.map((todo, index) => (
+
+
{getStatusIcon(todo.status)}
+
+
+ {todo.content}
+
+ {todo.status === "in_progress" && todo.activeForm && (
+
+ {todo.activeForm}
+
+ )}
+
+ {getStatusBadge(todo.status)}
+
+ ))}
+
+ );
+}
+
interface LogEntryItemProps {
entry: LogEntry;
isExpanded: boolean;
@@ -63,9 +230,54 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
const colors = getLogTypeColors(entry.type);
const hasContent = entry.content.length > 100;
+ // For tool_call entries, use tool-specific styling
+ const isToolCall = entry.type === "tool_call";
+ const toolCategory = entry.metadata?.toolCategory;
+ const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : "";
+
+ // Check if this is a TodoWrite entry and parse the todos
+ const isTodoWrite = entry.metadata?.toolName === "TodoWrite";
+ const parsedTodos = useMemo(() => {
+ if (!isTodoWrite) return null;
+ return parseTodoContent(entry.content);
+ }, [isTodoWrite, entry.content]);
+
+ // Get the appropriate icon based on entry type and tool category
+ const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type);
+
+ // Get collapsed preview text - prefer smart summary for tool calls
+ const collapsedPreview = useMemo(() => {
+ if (isExpanded) return "";
+
+ // Use smart summary if available
+ if (entry.metadata?.summary) {
+ return entry.metadata.summary;
+ }
+
+ // Fallback to truncated content
+ return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : "");
+ }, [isExpanded, entry.metadata?.summary, entry.content]);
+
// Format content - detect and highlight JSON
const formattedContent = useMemo(() => {
- const content = entry.content;
+ let content = entry.content;
+
+ // For tool_call entries, remove redundant "Tool: X" and "Input:" prefixes
+ // since we already show the tool name in the header badge
+ if (isToolCall) {
+ // Remove "š§ Tool: ToolName\n" or "Tool: ToolName\n" prefix
+ content = content.replace(/^(?:š§\s*)?Tool:\s*\w+\s*\n?/i, "");
+ // Remove standalone "Input:" label (keep the JSON that follows)
+ content = content.replace(/^Input:\s*\n?/i, "");
+ content = content.trim();
+ }
+
+ // For summary entries, remove the and tags
+ if (entry.title === "Summary") {
+ content = content.replace(/^\s*/i, "");
+ content = content.replace(/\s*<\/summary>\s*$/i, "");
+ content = content.trim();
+ }
// Try to find and format JSON blocks
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
@@ -103,14 +315,20 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
}
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
- }, [entry.content]);
+ }, [entry.content, entry.title, isToolCall]);
+
+ // Get colors - use tool category colors for tool_call entries
+ const colorParts = toolCategoryColors.split(" ");
+ const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text;
+ const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg;
+ const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border;
return (
)}
-
- {getLogIcon(entry.type)}
+
+ {icon}
@@ -145,9 +363,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
- {!isExpanded &&
- entry.content.slice(0, 80) +
- (entry.content.length > 80 ? "..." : "")}
+ {collapsedPreview}
@@ -156,36 +372,140 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
className="px-4 pb-3 pt-1"
data-testid={`log-entry-content-${entry.id}`}
>
-
- {formattedContent.map((part, index) => (
-
- {part.type === "json" ? (
-
- {part.content}
-
- ) : (
-
- {part.content}
-
- )}
-
- ))}
-
+ {/* Render TodoWrite entries with special formatting */}
+ {parsedTodos ? (
+
+ ) : (
+
+ {formattedContent.map((part, index) => (
+
+ {part.type === "json" ? (
+
+ {part.content}
+
+ ) : (
+
+ {part.content}
+
+ )}
+
+ ))}
+
+ )}
)}
);
}
+interface ToolCategoryStats {
+ read: number;
+ edit: number;
+ write: number;
+ bash: number;
+ search: number;
+ todo: number;
+ task: number;
+ other: number;
+}
+
export function LogViewer({ output, className }: LogViewerProps) {
const [expandedIds, setExpandedIds] = useState>(new Set());
+ const [searchQuery, setSearchQuery] = useState("");
+ const [hiddenTypes, setHiddenTypes] = useState>(new Set());
+ const [hiddenCategories, setHiddenCategories] = useState>(new Set());
- const entries = useMemo(() => parseLogOutput(output), [output]);
+ // Parse entries and compute initial expanded state together
+ const { entries, initialExpandedIds } = useMemo(() => {
+ const parsedEntries = parseLogOutput(output);
+ const toExpand: string[] = [];
+
+ parsedEntries.forEach((entry) => {
+ // If entry should NOT collapse by default, mark it for expansion
+ if (!shouldCollapseByDefault(entry)) {
+ toExpand.push(entry.id);
+ }
+ });
+
+ return {
+ entries: parsedEntries,
+ initialExpandedIds: new Set(toExpand),
+ };
+ }, [output]);
+
+ // Merge initial expanded IDs with user-toggled ones
+ // Use a ref to track if we've applied initial state
+ const appliedInitialRef = useRef>(new Set());
+
+ // Apply initial expanded state for new entries
+ const effectiveExpandedIds = useMemo(() => {
+ const result = new Set(expandedIds);
+ initialExpandedIds.forEach((id) => {
+ if (!appliedInitialRef.current.has(id)) {
+ appliedInitialRef.current.add(id);
+ result.add(id);
+ }
+ });
+ return result;
+ }, [expandedIds, initialExpandedIds]);
+
+ // Calculate stats for tool categories
+ const stats = useMemo(() => {
+ const toolCalls = entries.filter((e) => e.type === "tool_call");
+ const byCategory: ToolCategoryStats = {
+ read: 0,
+ edit: 0,
+ write: 0,
+ bash: 0,
+ search: 0,
+ todo: 0,
+ task: 0,
+ other: 0,
+ };
+
+ toolCalls.forEach((tc) => {
+ const cat = tc.metadata?.toolCategory || "other";
+ byCategory[cat]++;
+ });
+
+ return {
+ total: toolCalls.length,
+ byCategory,
+ errors: entries.filter((e) => e.type === "error").length,
+ };
+ }, [entries]);
+
+ // Filter entries based on search and hidden types/categories
+ const filteredEntries = useMemo(() => {
+ return entries.filter((entry) => {
+ // Filter by hidden types
+ if (hiddenTypes.has(entry.type)) return false;
+
+ // Filter by hidden tool categories (for tool_call entries)
+ if (entry.type === "tool_call" && entry.metadata?.toolCategory) {
+ if (hiddenCategories.has(entry.metadata.toolCategory)) return false;
+ }
+
+ // Filter by search query
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ return (
+ entry.content.toLowerCase().includes(query) ||
+ entry.title.toLowerCase().includes(query) ||
+ entry.metadata?.toolName?.toLowerCase().includes(query) ||
+ entry.metadata?.summary?.toLowerCase().includes(query) ||
+ entry.metadata?.filePath?.toLowerCase().includes(query)
+ );
+ }
+
+ return true;
+ });
+ }, [entries, hiddenTypes, hiddenCategories, searchQuery]);
const toggleEntry = (id: string) => {
setExpandedIds((prev) => {
@@ -200,13 +520,45 @@ export function LogViewer({ output, className }: LogViewerProps) {
};
const expandAll = () => {
- setExpandedIds(new Set(entries.map((e) => e.id)));
+ setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
};
const collapseAll = () => {
setExpandedIds(new Set());
};
+ const toggleTypeFilter = (type: LogEntryType) => {
+ setHiddenTypes((prev) => {
+ const next = new Set(prev);
+ if (next.has(type)) {
+ next.delete(type);
+ } else {
+ next.add(type);
+ }
+ return next;
+ });
+ };
+
+ const toggleCategoryFilter = (category: ToolCategory) => {
+ setHiddenCategories((prev) => {
+ const next = new Set(prev);
+ if (next.has(category)) {
+ next.delete(category);
+ } else {
+ next.add(category);
+ }
+ return next;
+ });
+ };
+
+ const clearFilters = () => {
+ setSearchQuery("");
+ setHiddenTypes(new Set());
+ setHiddenCategories(new Set());
+ };
+
+ const hasActiveFilters = searchQuery || hiddenTypes.size > 0 || hiddenCategories.size > 0;
+
if (entries.length === 0) {
return (
@@ -229,28 +581,123 @@ export function LogViewer({ output, className }: LogViewerProps) {
return acc;
}, {} as Record
);
+ // Tool categories to display in stats bar
+ const toolCategoryLabels: { key: ToolCategory; label: string }[] = [
+ { key: "read", label: "Read" },
+ { key: "edit", label: "Edit" },
+ { key: "write", label: "Write" },
+ { key: "bash", label: "Bash" },
+ { key: "search", label: "Search" },
+ { key: "todo", label: "Todo" },
+ { key: "task", label: "Task" },
+ { key: "other", label: "Other" },
+ ];
+
return (
-
- {/* Header with controls */}
+
+ {/* Sticky header with search, stats, and filters */}
+ {/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
+
+ {/* Search bar */}
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search logs..."
+ className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
+ data-testid="log-search-input"
+ />
+ {searchQuery && (
+
+ )}
+
+ {hasActiveFilters && (
+
+ )}
+
+
+ {/* Tool category stats bar */}
+ {stats.total > 0 && (
+
+
+
+ {stats.total} tools:
+
+ {toolCategoryLabels.map(({ key, label }) => {
+ const count = stats.byCategory[key];
+ if (count === 0) return null;
+ const isHidden = hiddenCategories.has(key);
+ const colorClasses = getToolCategoryColor(key);
+ return (
+
+ );
+ })}
+ {stats.errors > 0 && (
+
+
+ {stats.errors}
+
+ )}
+
+ )}
+
+ {/* Header with type filters and controls */}
-
+
+
{Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType);
+ const isHidden = hiddenTypes.has(type as LogEntryType);
return (
- toggleTypeFilter(type as LogEntryType)}
className={cn(
- "text-xs px-2 py-0.5 rounded-full",
- colors.badge
+ "text-xs px-2 py-0.5 rounded-full transition-all",
+ colors.badge,
+ isHidden && "opacity-40 line-through"
)}
- data-testid={`log-type-count-${type}`}
+ title={isHidden ? `Show ${type}` : `Hide ${type}`}
+ data-testid={`log-type-filter-${type}`}
>
{type}: {count}
-
+
);
})}
+
+ {filteredEntries.length}/{entries.length}
+
+
{/* Log entries */}
-
- {entries.map((entry) => (
-
toggleEntry(entry.id)}
- />
- ))}
+
+ {filteredEntries.length === 0 ? (
+
+ No entries match your filters.
+ {hasActiveFilters && (
+
+ )}
+
+ ) : (
+ filteredEntries.map((entry) => (
+
toggleEntry(entry.id)}
+ />
+ ))
+ )}
);
diff --git a/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx
index 0dd6429c..d92cabcc 100644
--- a/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx
+++ b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx
@@ -99,24 +99,6 @@ export function AgentOutputModal({
loadOutput();
}, [open, featureId]);
- // Save output to file
- const saveOutput = async (newContent: string) => {
- if (!projectPathRef.current) return;
-
- const api = getElectronAPI();
- if (!api) return;
-
- try {
- // Use features API - agent output is stored in features/{id}/agent-output.md
- // We need to write it directly since there's no updateAgentOutput method
- // The context-manager handles this on the backend, but for frontend edits we write directly
- const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`;
- await api.writeFile(outputPath, newContent);
- } catch (error) {
- console.error("Failed to save output:", error);
- }
- };
-
// Listen to auto mode events and update output
useEffect(() => {
if (!open) return;
@@ -142,7 +124,7 @@ export function AgentOutputModal({
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\nš§ Tool: ${toolName}\n${
- toolInput ? `Input: ${toolInput}` : ""
+ toolInput ? `Input: ${toolInput}\n` : ""
}`;
break;
case "auto_mode_phase":
@@ -202,11 +184,8 @@ export function AgentOutputModal({
}
if (newContent) {
- setOutput((prev) => {
- const updated = prev + newContent;
- saveOutput(updated);
- return updated;
- });
+ // Only update local state - server is the single source of truth for file writes
+ setOutput((prev) => prev + newContent);
}
});
diff --git a/apps/app/src/lib/agent-context-parser.ts b/apps/app/src/lib/agent-context-parser.ts
index 925c56fd..feb33678 100644
--- a/apps/app/src/lib/agent-context-parser.ts
+++ b/apps/app/src/lib/agent-context-parser.ts
@@ -130,9 +130,16 @@ function getCurrentPhase(content: string): "planning" | "action" | "verification
/**
* Extracts a summary from completed feature context
+ * Looks for content between
and tags
*/
function extractSummary(content: string): string | undefined {
- // Look for summary sections - capture everything including subsections (###)
+ // Look for
tags - capture everything between opening and closing tags
+ const summaryTagMatch = content.match(/([\s\S]*?)<\/summary>/i);
+ if (summaryTagMatch) {
+ return summaryTagMatch[1].trim();
+ }
+
+ // Fallback: Look for summary sections - capture everything including subsections (###)
// Stop at same-level ## sections (but not ###), or tool markers, or end
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\nš§|$)/i);
if (summaryMatch) {
diff --git a/apps/app/src/lib/log-parser.ts b/apps/app/src/lib/log-parser.ts
index 872b814d..85fa96c6 100644
--- a/apps/app/src/lib/log-parser.ts
+++ b/apps/app/src/lib/log-parser.ts
@@ -15,6 +15,38 @@ export type LogEntryType =
| "warning"
| "thinking";
+export type ToolCategory = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'todo' | 'task' | 'other';
+
+const TOOL_CATEGORIES: Record = {
+ 'Read': 'read',
+ 'Edit': 'edit',
+ 'Write': 'write',
+ 'Bash': 'bash',
+ 'Grep': 'search',
+ 'Glob': 'search',
+ 'WebSearch': 'search',
+ 'WebFetch': 'read',
+ 'TodoWrite': 'todo',
+ 'Task': 'task',
+ 'NotebookEdit': 'edit',
+ 'KillShell': 'bash',
+};
+
+/**
+ * Categorizes a tool name into a predefined category
+ */
+export function categorizeToolName(toolName: string): ToolCategory {
+ return TOOL_CATEGORIES[toolName] || 'other';
+}
+
+export interface LogEntryMetadata {
+ toolName?: string;
+ toolCategory?: ToolCategory;
+ filePath?: string;
+ summary?: string;
+ phase?: string;
+}
+
export interface LogEntry {
id: string;
type: LogEntryType;
@@ -22,11 +54,7 @@ export interface LogEntry {
content: string;
timestamp?: string;
collapsed?: boolean;
- metadata?: {
- toolName?: string;
- phase?: string;
- [key: string]: string | undefined;
- };
+ metadata?: LogEntryMetadata;
}
/**
@@ -93,11 +121,16 @@ function detectEntryType(content: string): LogEntryType {
return "error";
}
- // Success messages
+ // Success messages and summary sections
if (
trimmed.startsWith("ā
") ||
trimmed.toLowerCase().includes("success") ||
- trimmed.toLowerCase().includes("completed")
+ trimmed.toLowerCase().includes("completed") ||
+ // Summary tags (preferred format from agent)
+ trimmed.startsWith("") ||
+ // Markdown summary headers (fallback)
+ trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
+ trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)
) {
return "success";
}
@@ -107,10 +140,11 @@ function detectEntryType(content: string): LogEntryType {
return "warning";
}
- // Thinking/Preparation info
+ // Thinking/Preparation info (be specific to avoid matching summary content)
if (
trimmed.toLowerCase().includes("ultrathink") ||
- trimmed.toLowerCase().includes("thinking level") ||
+ trimmed.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) ||
+ trimmed.match(/^thinking level\s*$/i) ||
trimmed.toLowerCase().includes("estimated cost") ||
trimmed.toLowerCase().includes("estimated time") ||
trimmed.toLowerCase().includes("budget tokens") ||
@@ -135,9 +169,11 @@ function detectEntryType(content: string): LogEntryType {
/**
* Extracts tool name from a tool call entry
+ * Matches both "š§ Tool: Name" and "Tool: Name" formats
*/
function extractToolName(content: string): string | undefined {
- const match = content.match(/š§\s*Tool:\s*(\S+)/);
+ // Try emoji format first, then plain format
+ const match = content.match(/(?:š§\s*)?Tool:\s*(\S+)/);
return match?.[1];
}
@@ -159,6 +195,134 @@ function extractPhase(content: string): string | undefined {
return match?.[1]?.toLowerCase();
}
+/**
+ * Extracts file path from tool input JSON
+ */
+function extractFilePath(content: string): string | undefined {
+ try {
+ const inputMatch = content.match(/Input:\s*([\s\S]*)/);
+ if (!inputMatch) return undefined;
+
+ const jsonStr = inputMatch[1].trim();
+ const parsed = JSON.parse(jsonStr) as Record;
+
+ if (typeof parsed.file_path === 'string') return parsed.file_path;
+ if (typeof parsed.path === 'string') return parsed.path;
+ if (typeof parsed.notebook_path === 'string') return parsed.notebook_path;
+
+ return undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Generates a smart summary for tool calls based on the tool name and input
+ */
+export function generateToolSummary(toolName: string, content: string): string | undefined {
+ try {
+ // Try to parse JSON input
+ const inputMatch = content.match(/Input:\s*([\s\S]*)/);
+ if (!inputMatch) return undefined;
+
+ const jsonStr = inputMatch[1].trim();
+ const parsed = JSON.parse(jsonStr) as Record;
+
+ switch (toolName) {
+ case 'Read': {
+ const filePath = parsed.file_path as string | undefined;
+ return `Reading ${filePath?.split('/').pop() || 'file'}`;
+ }
+ case 'Edit': {
+ const filePath = parsed.file_path as string | undefined;
+ const fileName = filePath?.split('/').pop() || 'file';
+ return `Editing ${fileName}`;
+ }
+ case 'Write': {
+ const filePath = parsed.file_path as string | undefined;
+ return `Writing ${filePath?.split('/').pop() || 'file'}`;
+ }
+ case 'Bash': {
+ const command = parsed.command as string | undefined;
+ const cmd = command?.slice(0, 50) || '';
+ return `Running: ${cmd}${(command?.length || 0) > 50 ? '...' : ''}`;
+ }
+ case 'Grep': {
+ const pattern = parsed.pattern as string | undefined;
+ return `Searching for "${pattern?.slice(0, 30) || ''}"`;
+ }
+ case 'Glob': {
+ const pattern = parsed.pattern as string | undefined;
+ return `Finding files: ${pattern || ''}`;
+ }
+ case 'TodoWrite': {
+ const todos = parsed.todos as unknown[] | undefined;
+ const todoCount = todos?.length || 0;
+ return `${todoCount} todo item${todoCount !== 1 ? 's' : ''}`;
+ }
+ case 'Task': {
+ const subagentType = parsed.subagent_type as string | undefined;
+ const description = parsed.description as string | undefined;
+ return `${subagentType || 'Agent'}: ${description || ''}`;
+ }
+ case 'WebSearch': {
+ const query = parsed.query as string | undefined;
+ return `Searching: "${query?.slice(0, 40) || ''}"`;
+ }
+ case 'WebFetch': {
+ const url = parsed.url as string | undefined;
+ return `Fetching: ${url?.slice(0, 40) || ''}`;
+ }
+ case 'NotebookEdit': {
+ const notebookPath = parsed.notebook_path as string | undefined;
+ return `Editing notebook: ${notebookPath?.split('/').pop() || 'notebook'}`;
+ }
+ case 'KillShell': {
+ return 'Terminating shell session';
+ }
+ default:
+ return undefined;
+ }
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Determines if an entry should be collapsed by default
+ */
+export function shouldCollapseByDefault(entry: LogEntry): boolean {
+ // Collapse if content is long
+ if (entry.content.length > 200) return true;
+
+ // Collapse if contains multi-line JSON (> 5 lines)
+ const lineCount = entry.content.split('\n').length;
+ if (lineCount > 5 && (entry.content.includes('{') || entry.content.includes('['))) {
+ return true;
+ }
+
+ // Collapse TodoWrite with multiple items
+ if (entry.metadata?.toolName === 'TodoWrite') {
+ try {
+ const inputMatch = entry.content.match(/Input:\s*([\s\S]*)/);
+ if (inputMatch) {
+ const parsed = JSON.parse(inputMatch[1].trim()) as Record;
+ const todos = parsed.todos as unknown[] | undefined;
+ if (todos && todos.length > 1) return true;
+ }
+ } catch {
+ // Ignore parse errors
+ }
+ }
+
+ // Collapse Edit with code blocks
+ if (entry.metadata?.toolName === 'Edit' && entry.content.includes('old_string')) {
+ return true;
+ }
+
+ return false;
+}
+
/**
* Generates a title for a log entry
*/
@@ -183,8 +347,19 @@ function generateTitle(type: LogEntryType, content: string): string {
}
case "error":
return "Error";
- case "success":
+ case "success": {
+ // Check if it's a summary section
+ if (content.startsWith("") || content.includes("")) {
+ return "Summary";
+ }
+ if (content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i)) {
+ return "Summary";
+ }
+ if (content.match(/^All tasks completed/i) || content.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)) {
+ return "Summary";
+ }
return "Success";
+ }
case "warning":
return "Warning";
case "thinking":
@@ -198,6 +373,39 @@ function generateTitle(type: LogEntryType, content: string): string {
}
}
+/**
+ * Tracks bracket depth for JSON accumulation
+ */
+function calculateBracketDepth(line: string): { braceChange: number; bracketChange: number } {
+ let braceChange = 0;
+ let bracketChange = 0;
+ let inString = false;
+ let escapeNext = false;
+
+ for (const char of line) {
+ if (escapeNext) {
+ escapeNext = false;
+ continue;
+ }
+ if (char === '\\') {
+ escapeNext = true;
+ continue;
+ }
+ if (char === '"') {
+ inString = !inString;
+ continue;
+ }
+ if (inString) continue;
+
+ if (char === '{') braceChange++;
+ else if (char === '}') braceChange--;
+ else if (char === '[') bracketChange++;
+ else if (char === ']') bracketChange--;
+ }
+
+ return { braceChange, bracketChange };
+}
+
/**
* Parses raw log output into structured entries
*/
@@ -213,10 +421,33 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
let currentContent: string[] = [];
let entryStartLine = 0; // Track the starting line for deterministic ID generation
+ // JSON accumulation state
+ let inJsonAccumulation = false;
+ let jsonBraceDepth = 0;
+ let jsonBracketDepth = 0;
+
+ // Summary tag accumulation state
+ let inSummaryAccumulation = false;
+
const finalizeEntry = () => {
if (currentEntry && currentContent.length > 0) {
currentEntry.content = currentContent.join("\n").trim();
if (currentEntry.content) {
+ // Populate enhanced metadata for tool calls
+ const toolName = currentEntry.metadata?.toolName;
+ if (toolName && currentEntry.type === 'tool_call') {
+ const toolCategory = categorizeToolName(toolName);
+ const filePath = extractFilePath(currentEntry.content);
+ const summary = generateToolSummary(toolName, currentEntry.content);
+
+ currentEntry.metadata = {
+ ...currentEntry.metadata,
+ toolCategory,
+ filePath,
+ summary,
+ };
+ }
+
// Generate deterministic ID based on content and position
const entryWithId: LogEntry = {
...currentEntry as Omit,
@@ -226,6 +457,10 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
}
}
currentContent = [];
+ inJsonAccumulation = false;
+ jsonBraceDepth = 0;
+ jsonBracketDepth = 0;
+ inSummaryAccumulation = false;
};
let lineIndex = 0;
@@ -238,6 +473,35 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
continue;
}
+ // If we're in JSON accumulation mode, keep accumulating until depth returns to 0
+ if (inJsonAccumulation) {
+ currentContent.push(line);
+ const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine);
+ jsonBraceDepth += braceChange;
+ jsonBracketDepth += bracketChange;
+
+ // JSON is complete when depth returns to 0
+ if (jsonBraceDepth <= 0 && jsonBracketDepth <= 0) {
+ inJsonAccumulation = false;
+ jsonBraceDepth = 0;
+ jsonBracketDepth = 0;
+ }
+ lineIndex++;
+ continue;
+ }
+
+ // If we're in summary accumulation mode, keep accumulating until
+ if (inSummaryAccumulation) {
+ currentContent.push(line);
+ // Summary is complete when we see closing tag
+ if (trimmedLine.includes("")) {
+ inSummaryAccumulation = false;
+ // Don't finalize here - let normal flow handle it
+ }
+ lineIndex++;
+ continue;
+ }
+
// Detect if this line starts a new entry
const lineType = detectEntryType(trimmedLine);
const isNewEntry =
@@ -256,8 +520,17 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
trimmedLine.match(/\[ERROR\]/i) ||
trimmedLine.match(/\[Status\]/i) ||
trimmedLine.toLowerCase().includes("ultrathink preparation") ||
- trimmedLine.toLowerCase().includes("thinking level") ||
- (trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call");
+ trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) ||
+ // Summary tags (preferred format from agent)
+ trimmedLine.startsWith("") ||
+ // Agent summary sections (markdown headers - fallback)
+ trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
+ // Summary introduction lines
+ trimmedLine.match(/^All tasks completed/i) ||
+ trimmedLine.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i);
+
+ // Check if this is an Input: line that should trigger JSON accumulation
+ const isInputLine = trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call";
if (isNewEntry) {
// Finalize previous entry
@@ -277,9 +550,45 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
},
};
currentContent.push(trimmedLine);
+
+ // If this is a tag, start summary accumulation mode
+ if (trimmedLine.startsWith("") && !trimmedLine.includes("")) {
+ inSummaryAccumulation = true;
+ }
+ } else if (isInputLine && currentEntry) {
+ // Start JSON accumulation mode
+ currentContent.push(trimmedLine);
+
+ // Check if there's JSON on the same line after "Input:"
+ const inputContent = trimmedLine.replace(/^Input:\s*/, '');
+ if (inputContent) {
+ const { braceChange, bracketChange } = calculateBracketDepth(inputContent);
+ jsonBraceDepth = braceChange;
+ jsonBracketDepth = bracketChange;
+
+ // Only enter accumulation mode if JSON is incomplete
+ if (jsonBraceDepth > 0 || jsonBracketDepth > 0) {
+ inJsonAccumulation = true;
+ }
+ } else {
+ // Input: line with JSON starting on next line
+ inJsonAccumulation = true;
+ }
} else if (currentEntry) {
// Continue current entry
currentContent.push(line);
+
+ // Check if this line starts a JSON block
+ if (trimmedLine.startsWith('{') || trimmedLine.startsWith('[')) {
+ const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine);
+ if (braceChange > 0 || bracketChange > 0) {
+ jsonBraceDepth = braceChange;
+ jsonBracketDepth = bracketChange;
+ if (jsonBraceDepth > 0 || jsonBracketDepth > 0) {
+ inJsonAccumulation = true;
+ }
+ }
+ }
} else {
// Track starting line for deterministic ID
entryStartLine = lineIndex;
diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts
index cc7c4bd8..5c9f6785 100644
--- a/apps/server/src/services/auto-mode-service.ts
+++ b/apps/server/src/services/auto-mode-service.ts
@@ -531,13 +531,15 @@ Address the follow-up instructions above. Review the previous work and make the
}
// Use fullPrompt (already built above) with model and all images
+ // Pass previousContext so the history is preserved in the output file
await this.runAgent(
workDir,
featureId,
fullPrompt,
abortController,
allImagePaths.length > 0 ? allImagePaths : imagePaths,
- model
+ model,
+ previousContext || undefined
);
// Mark as waiting_approval for user review
@@ -1137,7 +1139,22 @@ Implement this feature by:
4. Add or update tests as needed
5. Ensure the code follows existing patterns and conventions
-When done, summarize what you implemented and any notes for the developer.`;
+When done, wrap your final summary in tags like this:
+
+
+## Summary: [Feature Title]
+
+### Changes Implemented
+- [List of changes made]
+
+### Files Modified
+- [List of files]
+
+### Notes for Developer
+- [Any important notes]
+
+
+This helps parse your summary correctly in the output logs.`;
return prompt;
}
@@ -1148,7 +1165,8 @@ When done, summarize what you implemented and any notes for the developer.`;
prompt: string,
abortController: AbortController,
imagePaths?: string[],
- model?: string
+ model?: string,
+ previousContent?: string
): Promise {
// CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set
// This prevents actual API calls during automated testing
@@ -1250,7 +1268,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
// Execute via provider
const stream = provider.executeQuery(options);
- let responseText = "";
+ // Initialize with previous content if this is a follow-up, with a separator
+ let responseText = previousContent
+ ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
+ : "";
// Agent output goes to .automaker directory
// Note: We use the original projectPath here (from config), not workDir
// because workDir might be a worktree path
@@ -1258,10 +1279,43 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
+ // Incremental file writing state
+ let writeTimeout: ReturnType | null = null;
+ const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
+
+ // Helper to write current responseText to file
+ const writeToFile = async (): Promise => {
+ try {
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
+ await fs.writeFile(outputPath, responseText);
+ } catch (error) {
+ // Log but don't crash - file write errors shouldn't stop execution
+ console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error);
+ }
+ };
+
+ // Debounced write - schedules a write after WRITE_DEBOUNCE_MS
+ const scheduleWrite = (): void => {
+ if (writeTimeout) {
+ clearTimeout(writeTimeout);
+ }
+ writeTimeout = setTimeout(() => {
+ writeToFile();
+ }, WRITE_DEBOUNCE_MS);
+ };
+
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
+ // Add separator before new text if we already have content and it doesn't end with newlines
+ if (responseText.length > 0 && !responseText.endsWith('\n\n')) {
+ if (responseText.endsWith('\n')) {
+ responseText += '\n';
+ } else {
+ responseText += '\n\n';
+ }
+ }
responseText += block.text || "";
// Check for authentication errors in the response
@@ -1277,33 +1331,49 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
);
}
+ // Schedule incremental file write (debounced)
+ scheduleWrite();
+
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: block.text,
});
} else if (block.type === "tool_use") {
+ // Emit event for real-time UI
this.emitAutoModeEvent("auto_mode_tool", {
featureId,
tool: block.name,
input: block.input,
});
+
+ // Also add to file output for persistence
+ if (responseText.length > 0 && !responseText.endsWith('\n')) {
+ responseText += '\n';
+ }
+ responseText += `\nš§ Tool: ${block.name}\n`;
+ if (block.input) {
+ responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
+ }
+ scheduleWrite();
}
}
} else if (msg.type === "error") {
// Handle error messages
throw new Error(msg.error || "Unknown error");
} else if (msg.type === "result" && msg.subtype === "success") {
- responseText = msg.result || responseText;
+ // Don't replace responseText - the accumulated content is the full history
+ // The msg.result is just a summary which would lose all tool use details
+ // Just ensure final write happens
+ scheduleWrite();
}
}
- // Save agent output
- try {
- await fs.mkdir(path.dirname(outputPath), { recursive: true });
- await fs.writeFile(outputPath, responseText);
- } catch {
- // May fail if directory doesn't exist
+ // Clear any pending timeout and do a final write to ensure all content is saved
+ if (writeTimeout) {
+ clearTimeout(writeTimeout);
}
+ // Final write - ensure all accumulated content is saved
+ await writeToFile();
}
private async executeFeatureWithContext(