mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 12:26:58 +10:00
233 lines
38 KiB
TypeScript
233 lines
38 KiB
TypeScript
|
|
import figures from 'figures';
|
||
|
|
import sample from 'lodash-es/sample.js';
|
||
|
|
import * as React from 'react';
|
||
|
|
import { useRef, useState } from 'react';
|
||
|
|
import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js';
|
||
|
|
import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js';
|
||
|
|
import { useElapsedTime } from '../../hooks/useElapsedTime.js';
|
||
|
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||
|
|
import { stringWidth } from '../../ink/stringWidth.js';
|
||
|
|
import { Box, Text } from '../../ink.js';
|
||
|
|
import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js';
|
||
|
|
import { summarizeRecentActivities } from '../../utils/collapseReadSearch.js';
|
||
|
|
import { formatDuration, formatNumber, truncateToWidth } from '../../utils/format.js';
|
||
|
|
import { toInkColor } from '../../utils/ink.js';
|
||
|
|
import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js';
|
||
|
|
type Props = {
|
||
|
|
teammate: InProcessTeammateTaskState;
|
||
|
|
isLast: boolean;
|
||
|
|
isSelected?: boolean;
|
||
|
|
isForegrounded?: boolean;
|
||
|
|
allIdle?: boolean;
|
||
|
|
showPreview?: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract the last 3 lines of content from a teammate's conversation.
|
||
|
|
* Shows recent activity from any message type (user or assistant).
|
||
|
|
*/
|
||
|
|
function getMessagePreview(messages: InProcessTeammateTaskState['messages']): string[] {
|
||
|
|
if (!messages?.length) return [];
|
||
|
|
const allLines: string[] = [];
|
||
|
|
const maxLineLength = 80;
|
||
|
|
|
||
|
|
// Collect lines from recent messages (newest first)
|
||
|
|
for (let i = messages.length - 1; i >= 0 && allLines.length < 3; i--) {
|
||
|
|
const msg = messages[i];
|
||
|
|
// Only process messages that have content (user/assistant messages)
|
||
|
|
if (!msg || msg.type !== 'user' && msg.type !== 'assistant' || !msg.message?.content?.length) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const content = msg.message.content;
|
||
|
|
for (const block of content) {
|
||
|
|
if (allLines.length >= 3) break;
|
||
|
|
if (!block || typeof block !== 'object') continue;
|
||
|
|
if ('type' in block && block.type === 'tool_use' && 'name' in block) {
|
||
|
|
// Try to show meaningful info from tool input
|
||
|
|
const input = 'input' in block ? block.input as Record<string, unknown> : null;
|
||
|
|
let toolLine = `Using ${block.name}…`;
|
||
|
|
if (input) {
|
||
|
|
// Look for common descriptive fields
|
||
|
|
const desc = input.description as string | undefined || input.prompt as string | undefined || input.command as string | undefined || input.query as string | undefined || input.pattern as string | undefined;
|
||
|
|
if (desc) {
|
||
|
|
toolLine = desc.split('\n')[0] ?? toolLine;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
allLines.push(truncateToWidth(toolLine, maxLineLength));
|
||
|
|
} else if ('type' in block && block.type === 'text' && 'text' in block) {
|
||
|
|
const textLines = (block.text as string).split('\n').filter(l => l.trim());
|
||
|
|
// Take from end of text (most recent lines)
|
||
|
|
for (let j = textLines.length - 1; j >= 0 && allLines.length < 3; j--) {
|
||
|
|
const line = textLines[j];
|
||
|
|
if (!line) continue;
|
||
|
|
allLines.push(truncateToWidth(line, maxLineLength));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reverse so oldest of the 3 is first (reading order)
|
||
|
|
return allLines.reverse();
|
||
|
|
}
|
||
|
|
export function TeammateSpinnerLine({
|
||
|
|
teammate,
|
||
|
|
isLast,
|
||
|
|
isSelected,
|
||
|
|
isForegrounded,
|
||
|
|
allIdle,
|
||
|
|
showPreview
|
||
|
|
}: Props): React.ReactNode {
|
||
|
|
const [randomVerb] = useState(() => teammate.spinnerVerb ?? sample(getSpinnerVerbs()));
|
||
|
|
const [pastTenseVerb] = useState(() => teammate.pastTenseVerb ?? sample(TURN_COMPLETION_VERBS));
|
||
|
|
const isHighlighted = isSelected || isForegrounded;
|
||
|
|
const treeChar = isHighlighted ? isLast ? '╘═' : '╞═' : isLast ? '└─' : '├─';
|
||
|
|
const nameColor = toInkColor(teammate.identity.color);
|
||
|
|
const {
|
||
|
|
columns
|
||
|
|
} = useTerminalSize();
|
||
|
|
|
||
|
|
// Track when teammate became idle (for "Idle for X..." display)
|
||
|
|
const idleStartRef = useRef<number | null>(null);
|
||
|
|
// Freeze elapsed time when entering all-idle state
|
||
|
|
const frozenDurationRef = useRef<string | null>(null);
|
||
|
|
|
||
|
|
// Track idle start time
|
||
|
|
if (teammate.isIdle && idleStartRef.current === null) {
|
||
|
|
idleStartRef.current = Date.now();
|
||
|
|
} else if (!teammate.isIdle) {
|
||
|
|
idleStartRef.current = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reset frozen duration when leaving all-idle state
|
||
|
|
if (!allIdle && frozenDurationRef.current !== null) {
|
||
|
|
frozenDurationRef.current = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get elapsed idle time (how long they've been idle) - for "Idle for X..." display
|
||
|
|
const idleElapsedTime = useElapsedTime(idleStartRef.current ?? Date.now(), teammate.isIdle && !allIdle);
|
||
|
|
|
||
|
|
// Freeze the duration when we first detect all idle
|
||
|
|
// Use the teammate's actual work time (since task started) for the past-tense display
|
||
|
|
if (allIdle && frozenDurationRef.current === null) {
|
||
|
|
frozenDurationRef.current = formatDuration(Math.max(0, Date.now() - teammate.startTime - (teammate.totalPausedMs ?? 0)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use frozen work duration when all idle, otherwise use idle elapsed time
|
||
|
|
const displayTime = allIdle ? frozenDurationRef.current ?? (() => {
|
||
|
|
throw new Error(`frozenDurationRef is null for idle teammate ${teammate.identity.agentName}`);
|
||
|
|
})() : idleElapsedTime;
|
||
|
|
|
||
|
|
// Layout: paddingLeft(3) + pointer(1) + space(1) + treeChar(2) + space(1) = 8 fixed chars
|
||
|
|
// Then optionally: @name + ": " OR just ": "
|
||
|
|
// Then: activity text + optional extras (stats, hints)
|
||
|
|
const basePrefix = 8;
|
||
|
|
const fullAgentName = `@${teammate.identity.agentName}`;
|
||
|
|
const fullNameWidth = stringWidth(fullAgentName);
|
||
|
|
|
||
|
|
// Get stats from progress
|
||
|
|
const toolUseCount = teammate.progress?.toolUseCount ?? 0;
|
||
|
|
const tokenCount = teammate.progress?.tokenCount ?? 0;
|
||
|
|
const statsText = ` · ${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'} · ${formatNumber(tokenCount)} tokens`;
|
||
|
|
const statsWidth = stringWidth(statsText);
|
||
|
|
const selectHintText = ` · ${TEAMMATE_SELECT_HINT}`;
|
||
|
|
const selectHintWidth = stringWidth(selectHintText);
|
||
|
|
const viewHintText = ' · enter to view';
|
||
|
|
const viewHintWidth = stringWidth(viewHintText);
|
||
|
|
|
||
|
|
// Progressive responsive layout:
|
||
|
|
// Wide (80+): full name + activity + stats + hint
|
||
|
|
// Medium (60-80): full name + activity
|
||
|
|
// Narrow (<60): hide name, just show activity
|
||
|
|
const minActivityWidth = 25;
|
||
|
|
|
||
|
|
// Hide name on narrow terminals (< 60 cols) or if there's not enough room
|
||
|
|
const spaceWithFullName = columns - basePrefix - fullNameWidth - 2;
|
||
|
|
const showName = columns >= 60 && spaceWithFullName >= minActivityWidth;
|
||
|
|
const nameWidth = showName ? fullNameWidth + 2 : 0; // +2 for ": " when name shown
|
||
|
|
const availableForActivity = columns - basePrefix - nameWidth;
|
||
|
|
|
||
|
|
// Progressive hiding: view hint → select hint → stats
|
||
|
|
// Stats always visible (dimmed when not selected); hints only when highlighted/selected
|
||
|
|
const showViewHint = isSelected && !isForegrounded && availableForActivity > viewHintWidth + statsWidth + minActivityWidth + 5;
|
||
|
|
const showSelectHint = isHighlighted && availableForActivity > selectHintWidth + (showViewHint ? viewHintWidth : 0) + statsWidth + minActivityWidth + 5;
|
||
|
|
const showStats = availableForActivity > statsWidth + minActivityWidth + 5;
|
||
|
|
|
||
|
|
// Activity text gets remaining space
|
||
|
|
const extrasCost = (showStats ? statsWidth : 0) + (showSelectHint ? selectHintWidth : 0) + (showViewHint ? viewHintWidth : 0);
|
||
|
|
const activityMaxWidth = Math.max(minActivityWidth, availableForActivity - extrasCost - 1);
|
||
|
|
|
||
|
|
// Format the activity text for active teammates, rolling up search/read ops
|
||
|
|
const activityText = (() => {
|
||
|
|
const activities = teammate.progress?.recentActivities;
|
||
|
|
if (activities && activities.length > 0) {
|
||
|
|
const summary = summarizeRecentActivities(activities);
|
||
|
|
if (summary) return truncateToWidth(summary, activityMaxWidth);
|
||
|
|
}
|
||
|
|
const desc = teammate.progress?.lastActivity?.activityDescription;
|
||
|
|
if (desc) return truncateToWidth(desc, activityMaxWidth);
|
||
|
|
return randomVerb;
|
||
|
|
})();
|
||
|
|
|
||
|
|
// Status rendering logic
|
||
|
|
const renderStatus = (): React.ReactNode => {
|
||
|
|
if (teammate.shutdownRequested) {
|
||
|
|
return <Text dimColor>[stopping]</Text>;
|
||
|
|
}
|
||
|
|
if (teammate.awaitingPlanApproval) {
|
||
|
|
return <Text color="warning">[awaiting approval]</Text>;
|
||
|
|
}
|
||
|
|
if (teammate.isIdle) {
|
||
|
|
if (allIdle) {
|
||
|
|
return <Text dimColor>
|
||
|
|
{pastTenseVerb} for {displayTime}
|
||
|
|
</Text>;
|
||
|
|
}
|
||
|
|
return <Text dimColor>Idle for {idleElapsedTime}</Text>;
|
||
|
|
}
|
||
|
|
// Active - show spinner glyph + activity description (only when not highlighted;
|
||
|
|
// when highlighted, the main spinner above already shows the verb)
|
||
|
|
if (isHighlighted) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return <Text dimColor>
|
||
|
|
{activityText?.endsWith('…') ? activityText : `${activityText}…`}
|
||
|
|
</Text>;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Get preview lines if enabled
|
||
|
|
const previewLines = showPreview ? getMessagePreview(teammate.messages) : [];
|
||
|
|
|
||
|
|
// Tree continuation character for preview lines
|
||
|
|
const previewTreeChar = isLast ? ' ' : '│ ';
|
||
|
|
return <Box flexDirection="column">
|
||
|
|
<Box paddingLeft={3}>
|
||
|
|
{/* Selection indicator: pointer when selected, otherwise space */}
|
||
|
|
<Text color={isSelected ? 'suggestion' : undefined} bold={isSelected}>
|
||
|
|
{isSelected ? figures.pointer : ' '}
|
||
|
|
</Text>
|
||
|
|
<Text dimColor={!isSelected}>{treeChar} </Text>
|
||
|
|
{/* Agent name: hidden on very narrow screens */}
|
||
|
|
{showName && <Text color={isSelected ? 'suggestion' : nameColor}>
|
||
|
|
@{teammate.identity.agentName}
|
||
|
|
</Text>}
|
||
|
|
{showName && <Text dimColor={!isSelected}>: </Text>}
|
||
|
|
{renderStatus()}
|
||
|
|
{/* Stats: only shown when selected and terminal is wide enough */}
|
||
|
|
{showStats && <Text dimColor>
|
||
|
|
{' '}
|
||
|
|
· {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} ·{' '}
|
||
|
|
{formatNumber(tokenCount)} tokens
|
||
|
|
</Text>}
|
||
|
|
{/* Hints: select hint when highlighted, view hint when selected but not foregrounded */}
|
||
|
|
{showSelectHint && <Text dimColor> · {TEAMMATE_SELECT_HINT}</Text>}
|
||
|
|
{showViewHint && <Text dimColor> · enter to view</Text>}
|
||
|
|
</Box>
|
||
|
|
{/* Preview lines */}
|
||
|
|
{previewLines.map((line, idx) => <Box key={idx} paddingLeft={3}>
|
||
|
|
<Text dimColor> </Text>
|
||
|
|
<Text dimColor>{previewTreeChar} </Text>
|
||
|
|
<Text dimColor>{line}</Text>
|
||
|
|
</Box>)}
|
||
|
|
</Box>;
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmaWd1cmVzIiwic2FtcGxlIiwiUmVhY3QiLCJ1c2VSZWYiLCJ1c2VTdGF0ZSIsImdldFNwaW5uZXJWZXJicyIsIlRVUk5fQ09NUExFVElPTl9WRVJCUyIsInVzZUVsYXBzZWRUaW1lIiwidXNlVGVybWluYWxTaXplIiwic3RyaW5nV2lkdGgiLCJCb3giLCJUZXh0IiwiSW5Qcm9jZXNzVGVhbW1hdGVUYXNrU3RhdGUiLCJzdW1tYXJpemVSZWNlbnRBY3Rpdml0aWVzIiwiZm9ybWF0RHVyYXRpb24iLCJmb3JtYXROdW1iZXIiLCJ0cnVuY2F0ZVRvV2lkdGgiLCJ0b0lua0NvbG9yIiwiVEVBTU1BVEVfU0VMRUNUX0hJTlQiLCJQcm9wcyIsInRlYW1tYXRlIiwiaXNMYXN0IiwiaXNTZWxlY3RlZCIsImlzRm9yZWdyb3VuZGVkIiwiYWxsSWRsZSIsInNob3dQcmV2aWV3IiwiZ2V0TWVzc2FnZVByZXZpZXciLCJtZXNzYWdlcyIsImxlbmd0aCIsImFsbExpbmVzIiwibWF4TGluZUxlbmd0aCIsImkiLCJtc2ciLCJ0eXBlIiwibWVzc2FnZSIsImNvbnRlbnQiLCJibG9jayIsImlucHV0IiwiUmVjb3JkIiwidG9vbExpbmUiLCJuYW1lIiwiZGVzYyIsImRlc2NyaXB0aW9uIiwicHJvbXB0IiwiY29tbWFuZCIsInF1ZXJ5IiwicGF0dGVybiIsInNwbGl0IiwicHVzaCIsInRleHRMaW5lcyIsInRleHQiLCJmaWx0ZXIiLCJsIiwidHJpbSIsImoiLCJsaW5lIiwicmV2ZXJzZSIsIlRlYW1tYXRlU3Bpbm5lckxpbmUiLCJSZWFjdE5vZGUiLCJyYW5kb21WZXJiIiwic3Bpbm5lclZlcmIiLCJwYXN0VGVuc2VWZXJiIiwiaXNIaWdobGlnaHRlZCIsInRyZWVDaGFyIiwibmFtZUNvbG9yIiwiaWRlbnRpdHkiLCJjb2xvciIsImNvbHVtbnMiLCJpZGxlU3RhcnRSZWYiLCJmcm96ZW5EdXJhdGlvblJlZiIsImlzSWRsZSIsImN1cnJlbnQiLCJEYXRlIiwibm93IiwiaWRsZUVsYXBzZWRUaW1lIiwiTWF0aCIsIm1heCIsInN0YXJ0VGltZSIsInRvdGFsUGF1c2VkTXMiLCJkaXNwbGF5VGltZSIsIkVycm9yIiwiYWdlbnROYW1lIiwiYmFzZVByZWZpeCIsImZ1bGxBZ2VudE5hbWUiLCJmdWxsTmFtZVdpZHRoIiwidG9vbFVzZUNvdW50IiwicHJvZ3Jlc3MiLCJ0b2tlbkNvdW50Iiwic3RhdHNUZXh0Iiwic3RhdHNXaWR0aCIsInNlbGVjdEhpbnRUZXh0Iiwic2VsZWN0SGludFdpZHRoIiwidmlld0hpbnRUZXh0Iiwidmlld0hpbnRXaWR0aCIsIm1pbkFjdGl2aXR5V2lkdGgiLCJzcGFjZVdpdGhGdWxsTmFtZSIsInNob3dOYW1lIiwibmFtZVdpZHRoIiwiYXZhaWxhYmxlRm9yQWN0aXZpdHkiLCJzaG93Vmlld0hpbnQiLCJzaG93U2VsZWN0SGludCIsInNob3dTdGF0cyIsImV4dHJhc0Nvc3QiLCJhY3Rpdml0eU1heFdpZHRoIiwiYWN0aXZpdHlUZXh0IiwiYWN0aXZpdGllcyIsInJlY2VudEFjdGl2aXRpZXMiLCJzdW1tYXJ5IiwibGFzdEFjdGl2aXR5IiwiYWN0aXZpdHlEZXNjcmlwdGlvbiIsInJlbmRlclN0YXR1cyIsInNodXRkb3duUmVxdWVzdGVkIiwiYXdhaXRpbmdQbGFuQXBwcm92YWwiLCJlbmRzV2l0aCIsInByZXZpZXdMaW5lcyIsInByZXZpZXdUcmVlQ2hhciIsInVuZGVmaW5lZCIsInBvaW50ZXIiLCJtYXAiLCJpZHgiXSwic291cmNlcyI6WyJUZWFtbWF0ZVNwaW5uZXJMaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgZmlndXJlcyBmcm9tICdmaWd1cmVzJ1xuaW1wb3J0IHNhbXBsZSBmcm9tICdsb2Rhc2gtZXMvc2FtcGxlLmpzJ1xuaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VSZWYsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBnZXRTcGlubmVyVmVyYnMgfSBmcm9tICcuLi8uLi9jb25zdGFudHMvc3Bpbm5lclZlcmJzLmpzJ1xuaW1wb3J0IHsgVFVSTl9DT01QTEVUSU9OX1ZFUkJTIH0gZnJvbSAnLi4vLi4vY29uc3RhbnRzL3R1cm5Db21wbGV0aW9uVmVyYnMuanMnXG5pbXBvcnQgeyB1c2VFbGFwc2VkVGltZSB9IGZyb20gJy4uLy4uL2hvb2tzL3VzZUVsYXBzZWRUaW1lLmpzJ1xuaW1wb3J0IHsgdXNlVGVybWluYWxTaXplIH0gZnJvbSAnLi4vLi4vaG9va3MvdXNlVGVybWluYWxTaXplLmpzJ1xuaW1wb3J0IHsgc3RyaW5nV2lkdGggfSBmcm9tICcuLi8uLi9pbmsvc3RyaW5nV2lkdGguanMnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi8uLi9pbmsuanMnXG5pbXBvcnQgdHlwZSB7IEluUHJvY2Vzc1RlYW1tYXRlVGFza1N0YXRlIH0gZnJvbSAnLi4vLi4vdGFza3MvSW5Qcm9jZXNzVGVhbW1hdGVUYXNrL3R5cGVzLmpzJ1xuaW1wb3J0IHsgc3VtbWFyaXplUmVjZW50QWN0aXZpdGllcyB9IGZyb20gJy4uLy4uL3V0aWxzL2NvbGxhcHNlUmVhZFNlYXJjaC5qcydcbmltcG9ydCB7XG4gIGZvcm1hdER1cmF0aW9uLFxuICBmb3JtYXROdW1iZXIsXG4gIHRydW5jYXRlVG9XaWR0aCxcbn0gZnJvbSAnLi4vLi4vdXRpbHMvZm9ybWF0LmpzJ1xuaW1wb3J0IHsgdG9JbmtDb2xvciB9IGZyb20gJy4uLy4uL3V0aWxzL2luay5qcydcbmltcG9ydCB7IFRFQU1NQVRFX1NFTEVDVF9ISU5UIH0gZnJvbSAnLi90ZWFtbWF0ZVNlbGVjdEhpbnQuanMnXG5cbnR5cGUgUHJvcHMgPSB7XG4gIHRlYW1tYXRlOiBJblByb2Nlc3NUZWFtbWF0ZVRhc2tTdGF0ZVxuICBpc0xhc3Q6IGJvb2xlYW5cbiAgaXNTZWxlY3RlZD86IGJvb2xlYW5cbiAgaXNGb3JlZ3JvdW5kZWQ/OiBib29sZWFuXG4gIGFsbElkbGU/OiBib29sZWFuXG4gIHNob3dQcmV2aWV3PzogYm9vbGVhblxufVxuXG4vKipcbiAqIEV4dHJhY3QgdGhlIGxhc3QgMyBsaW5lcyBvZiBjb250ZW50IGZyb20gYSB0ZWFtbWF0ZSdzIGNvbnZlcnNhdGlvbi5cbiAqIFNob3dzIHJlY2VudCBhY3Rpdml0eSBmcm9tIGFueSBtZXNzYWdlIHR5cGUgKHVzZXIgb3IgYXNzaXN0YW50KS5cbiAqL1xuZnVuY3Rpb24gZ2V0TWVzc2FnZVByZXZpZXcoXG4gIG1lc3NhZ2VzOiBJblByb2Nlc3NUZWFtbWF0ZVRhc2tTdGF0ZVsnbWVzc2FnZXMnXSxcbik6IHN0cmluZ1tdIHtcbiAgaWYgKCFtZXNzYWdlcz8ubGVuZ3RoKSByZXR1cm4gW11cblxuICBjb25zdCBhbGxMaW5lczogc3RyaW5nW10gPSBbXVxuICBjb25zdCBtYXh
|