diff --git a/src/cli/commands/chat.tsx b/src/cli/commands/chat.tsx index 06119fb..cb1279e 100644 --- a/src/cli/commands/chat.tsx +++ b/src/cli/commands/chat.tsx @@ -20,7 +20,7 @@ import {TextInput} from '../../ui/components/TextInput.js'; import {theme} from '../../ui/theme.js'; import {handleSlashCommand, type CommandContext} from './commands.js'; import {runAgentTurn, type Message, type TokenUsage} from './streaming.js'; -import {formatContextReport} from './formatters.js'; +import {formatContextReport, formatElapsedTimeWhole} from './formatters.js'; import {type LlmLog, createLog as createLlmLog, endLog as endLlmLog} from '../../core/log/llmLog.js'; import {loadSkillRegistry} from '../../skills/SkillRegistry.js'; import {createSkill, toSkillDirName} from '../../skills/builder/SkillBuilder.js'; @@ -71,6 +71,13 @@ async function currentBranchName() { } } +/** Elapsed-time label for the busy indicator heartbeat, or '' when no turn is active. */ +function busyElapsedLabel(startedAt: number | undefined) { + if (startedAt == null) return ''; + const elapsed = Date.now() - startedAt; + return elapsed > 0 ? formatElapsedTimeWhole(elapsed) : ''; +} + function ChatScreen({debug = false, version, continueSession = false, noSession = false}: ChatOptions) { const {exit} = useApp(); const {stdout} = useStdout(); @@ -113,6 +120,11 @@ function ChatScreen({debug = false, version, continueSession = false, noSession const [busy, setBusy] = useState(false); const [busyLabel, setBusyLabel] = useState('Haze is thinking'); const [, setActiveGoalStatus] = useState(); + // Heartbeat for the busy indicator: ticks every second while Haze is working + // so the developer always sees rolling activity (elapsed turn time) even when + // the model is thinking with no streamed output and no tool is running. + const turnStartedAtRef = useRef(undefined); + const [, setBusyTick] = useState(0); const [visibleTasks, setVisibleTasks] = useState([]); const [tasksExpanded, setTasksExpanded] = useState(false); const [taskBarPadding, setTaskBarPadding] = useState(0); @@ -131,6 +143,21 @@ function ChatScreen({debug = false, version, continueSession = false, noSession const [selectedMcpName, setSelectedMcpName] = useState(); const [mcpDraft, setMcpDraft] = useState>({}); + // Wrap setBusy so the busy indicator knows when the turn started, and tick a + // heartbeat every second while busy so elapsed time keeps rolling. This keeps + // the UI visibly alive during long model thinking / blocked tool runs where + // otherwise no streamed output is produced (the "looks stuck" problem). + const setBusyWithHeartbeat = (nextBusy: boolean) => { + if (nextBusy && !busy) turnStartedAtRef.current = Date.now(); + if (!nextBusy) turnStartedAtRef.current = undefined; + setBusy(nextBusy); + }; + useEffect(() => { + if (!busy) return; + const heartbeat = setInterval(() => setBusyTick(tick => tick + 1), 1000); + return () => clearInterval(heartbeat); + }, [busy]); + useEffect(() => { Promise.all([ readSettings().catch(() => ({} as HazeSettings)), @@ -339,7 +366,7 @@ function ChatScreen({debug = false, version, continueSession = false, noSession setQueuedFollowUps([]); setMessages(m => [...m, {role: 'system', text: 'Cleared queued follow-ups after interrupt.'}]); } - setBusy(false); + setBusyWithHeartbeat(false); } function queueFollowUp(value: string) { @@ -650,7 +677,7 @@ function ChatScreen({debug = false, version, continueSession = false, noSession const name = result.draftName; const description = result.description; setBusyLabel(result.busyLabel ?? 'Creating skill'); - setBusy(true); + setBusyWithHeartbeat(true); try { const created = await createSkill({name, description}); setMessages(m => [...m, {role: 'system', text: skillCreationMessage(created.name, created.file)}]); @@ -658,7 +685,7 @@ function ChatScreen({debug = false, version, continueSession = false, noSession } catch (error) { setMessages(m => [...m, {role: 'system', text: skillCreationFailure(error)}]); } finally { - setBusy(false); + setBusyWithHeartbeat(false); setBusyLabel('Haze is thinking'); } } @@ -1063,7 +1090,7 @@ function ChatScreen({debug = false, version, continueSession = false, noSession conversationRef.current = msgs; sessionRecorder.recordConversation(msgs); }, - setBusy, + setBusy: setBusyWithHeartbeat, setBusyLabel, debugLog, getConversation: () => conversationRef.current, @@ -1148,7 +1175,7 @@ function ChatScreen({debug = false, version, continueSession = false, noSession } {busy && - {busyLabel} · type to queue follow-up · esc to interrupt + {busyLabel}{busyElapsedLabel(turnStartedAtRef.current) ? · {busyElapsedLabel(turnStartedAtRef.current)} : null} · type to queue follow-up · esc to interrupt } diff --git a/src/cli/commands/formatters.ts b/src/cli/commands/formatters.ts index 59ff7ee..0cc9aa0 100644 --- a/src/cli/commands/formatters.ts +++ b/src/cli/commands/formatters.ts @@ -102,6 +102,42 @@ export function toolResultSummary(event: {success: boolean; output?: unknown; er return 'completed'; } +/** + * A short, human label for the live busy indicator while a tool is running, + * e.g. "Running command", "Reading src/foo.ts", "Searching". + * Lets the developer see *what* is happening, not just that something is. + */ +export function busyToolLabel(toolName: string, input: unknown) { + const data = input as Record; + switch (toolName) { + case 'bash': + return 'Running command'; + case 'grep': + return 'Searching'; + case 'listFiles': + return 'Listing files'; + case 'readFile': + return typeof data?.path === 'string' ? `Reading ${data.path}` : 'Reading file'; + case 'writeFile': + return typeof data?.path === 'string' ? `Writing ${data.path}` : 'Writing file'; + case 'editFile': + case 'replaceLines': + return typeof data?.path === 'string' ? `Editing ${data.path}` : 'Editing file'; + case 'fetch': + return 'Fetching URL'; + case 'subagent': + return 'Running subagent'; + case 'writeTasks': + return 'Updating tasks'; + case 'skill': + return 'Loading skill'; + default: + if (toolName.startsWith('lsp')) return 'Querying LSP'; + if (toolName.startsWith('mcp')) return 'Running MCP tool'; + return `Running ${toolName}`; + } +} + export function formatSeconds(milliseconds: number) { return `${(milliseconds / 1000).toFixed(1)}s`; } diff --git a/src/cli/commands/streaming.ts b/src/cli/commands/streaming.ts index 7506b77..326b507 100644 --- a/src/cli/commands/streaming.ts +++ b/src/cli/commands/streaming.ts @@ -6,7 +6,7 @@ import {assembleRequestContext} from '../../llm/requestContext.js'; import {projectContextSection, type PromptSession} from '../../llm/systemPrompt.js'; import {closeMcpClients, type LoadedMcpTools} from '../../llm/mcp.js'; import type {ContextFile} from '../../config/contextFiles.js'; -import {toolCallSummary, toolResultSummary, formatSeconds} from './formatters.js'; +import {toolCallSummary, toolResultSummary, busyToolLabel, formatSeconds} from './formatters.js'; import {agentEvent, type AgentEventSink} from '../../core/agent/events.js'; import {isContextOverflowError, isRetryableModelError} from '../../core/agent/errors.js'; import {isPlanOnlyRequest} from '../../core/goal/requestClassifier.js'; @@ -254,6 +254,7 @@ export async function runAgentTurn( resetIdleTimer(); switch (part.type) { case 'text-delta': { + callbacks.setBusyLabel?.('Haze is thinking'); toolDisplay.startFreshToolGroup(); const delta = sanitizeAssistantText(part.text); assistantText += delta; @@ -280,6 +281,7 @@ export async function runAgentTurn( const toolCall = {toolCallId: part.id, toolName: part.toolName, input: {}}; latestToolCalls.set(part.id, toolCall); startedTools.set(part.id, Date.now()); + callbacks.setBusyLabel?.(busyToolLabel(part.toolName, {})); toolDisplay.ensureToolItem(toolCall); break; } @@ -292,6 +294,7 @@ export async function runAgentTurn( const toolCall = {toolCallId: part.toolCallId, toolName: part.toolName, input: part.input}; latestToolCalls.set(part.toolCallId, toolCall); if (!startedTools.has(part.toolCallId)) startedTools.set(part.toolCallId, Date.now()); + callbacks.setBusyLabel?.(busyToolLabel(part.toolName, part.input)); toolDisplay.ensureToolItem(toolCall).summary = toolCallSummary(part.toolName, part.input); toolDisplay.updateToolGroup(true); break; diff --git a/tests/cli/formatters.test.ts b/tests/cli/formatters.test.ts index 49f4984..3b33b43 100644 --- a/tests/cli/formatters.test.ts +++ b/tests/cli/formatters.test.ts @@ -1,5 +1,5 @@ import {describe, it, expect} from 'vitest'; -import {compact, toolCallSummary, toolResultSummary, formatSeconds, formatElapsedTime, formatElapsedTimeWhole, formatContextReport, type ContextReportData} from '../../src/cli/commands/formatters.js'; +import {compact, toolCallSummary, toolResultSummary, busyToolLabel, formatSeconds, formatElapsedTime, formatElapsedTimeWhole, formatContextReport, type ContextReportData} from '../../src/cli/commands/formatters.js'; describe('compact', () => { it('returns short strings unchanged', () => { @@ -87,6 +87,44 @@ describe('toolCallSummary', () => { }); }); +describe('busyToolLabel', () => { + it('labels bash as running a command', () => { + expect(busyToolLabel('bash', {command: 'npm test'})).toBe('Running command'); + }); + + it('labels readFile with its path', () => { + expect(busyToolLabel('readFile', {path: 'src/index.ts'})).toBe('Reading src/index.ts'); + }); + + it('labels readFile without input generically', () => { + expect(busyToolLabel('readFile', {})).toBe('Reading file'); + }); + + it('labels editFile and replaceLines as editing', () => { + expect(busyToolLabel('editFile', {path: 'a.ts'})).toBe('Editing a.ts'); + expect(busyToolLabel('replaceLines', {path: 'b.ts'})).toBe('Editing b.ts'); + }); + + it('labels grep and listFiles', () => { + expect(busyToolLabel('grep', {pattern: 'x'})).toBe('Searching'); + expect(busyToolLabel('listFiles', {path: '.'})).toBe('Listing files'); + }); + + it('labels fetch and subagent', () => { + expect(busyToolLabel('fetch', {url: 'https://example.com'})).toBe('Fetching URL'); + expect(busyToolLabel('subagent', {task: 'x'})).toBe('Running subagent'); + }); + + it('falls back to Running for unknown tools', () => { + expect(busyToolLabel('customTool', {data: 1})).toBe('Running customTool'); + }); + + it('labels LSP- and MCP-prefixed tools generically', () => { + expect(busyToolLabel('lspSymbols', {path: 'a.ts'})).toBe('Querying LSP'); + expect(busyToolLabel('mcp_search', {})).toBe('Running MCP tool'); + }); +}); + describe('toolResultSummary', () => { it('reports failure', () => { expect(toolResultSummary({success: false, error: 'bad'})).toBe('failed: bad');