Skip to content
Draft
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
39 changes: 33 additions & 6 deletions src/cli/commands/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string | undefined>();
// 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<number | undefined>(undefined);
const [, setBusyTick] = useState(0);
const [visibleTasks, setVisibleTasks] = useState<Task[]>([]);
const [tasksExpanded, setTasksExpanded] = useState(false);
const [taskBarPadding, setTaskBarPadding] = useState(0);
Expand All @@ -131,6 +143,21 @@ function ChatScreen({debug = false, version, continueSession = false, noSession
const [selectedMcpName, setSelectedMcpName] = useState<string | undefined>();
const [mcpDraft, setMcpDraft] = useState<Partial<HazeMcpServer>>({});

// 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)),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -650,15 +677,15 @@ 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)}]);
await refreshSkills();
} catch (error) {
setMessages(m => [...m, {role: 'system', text: skillCreationFailure(error)}]);
} finally {
setBusy(false);
setBusyWithHeartbeat(false);
setBusyLabel('Haze is thinking');
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1148,7 +1175,7 @@ function ChatScreen({debug = false, version, continueSession = false, noSession
<TaskBar tasks={visibleTasks} width={width} expanded={tasksExpanded} padding={taskBarPadding} />
</Box>}
{busy && <Box flexShrink={0}>
<Text><Text color={theme.orange} bold><Spinner type="dots" /> {busyLabel}</Text><Text color={theme.muted} dimColor> · type to queue follow-up · esc to interrupt</Text></Text>
<Text><Text color={theme.orange} bold><Spinner type="dots" /> {busyLabel}{busyElapsedLabel(turnStartedAtRef.current) ? <Text color={theme.muted} dimColor> · {busyElapsedLabel(turnStartedAtRef.current)}</Text> : null}</Text><Text color={theme.muted} dimColor> · type to queue follow-up · esc to interrupt</Text></Text>
</Box>}
<Box borderStyle="round" borderColor={theme.deepPurple} paddingX={1} flexShrink={0}>
<Box flexGrow={1} minWidth={0}>
Expand Down
36 changes: 36 additions & 0 deletions src/cli/commands/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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`;
}
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
40 changes: 39 additions & 1 deletion tests/cli/formatters.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 <name> 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');
Expand Down
Loading