feat: goal dot status color cycle
Browse files问题找到了。goalStatusColor 返回 'red', 'yellow' 等原始颜色字符串,但 ThemedText.resolveColor() 期望的是:
- 要么是 theme key(如 claude, success 等)
- 要么是带 ansi: 前缀的原始颜色(如 'ansi:red')
由于 'red' 既不是 theme key 也没有前缀,resolveColor 找不到对应颜色就返回 undefined,导致无色(灰色)。
修复方案:goalStatusColor 返回带 'ansi:' 前缀的颜色字符串。
● Update(src/components/PromptInput/GoalIndicator.tsx)
⎿ Added 6 lines, removed 6 lines
8 export function goalStatusColor(status: GoalStatus): string {
9 switch (status) {
10 case 'pursuing':
11 - return 'yellow'
11 + return 'ansi:yellow'
12 case 'paused':
13 - return 'gray'
13 + return 'ansi:gray'
14 case 'achieved':
15 - return 'green'
15 + return 'ansi:green'
16 case 'blocked':
17 - return 'red'
17 + return 'ansi:red'
18 case 'usage-limited':
19 - return 'gray'
19 + return 'ansi:gray'
20 case 'budget-limited':
21 - return 'magenta'
21 + return 'ansi:magenta'
22 }
23 }
24
● 现在验证构建是否通过。
● Bash(bun run tsc --noEmit 2>&1 | head -20)
⎿ tsconfig.json(10,5): error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption 'ignoreDeprecatio
|
@@ -8,17 +8,17 @@ const MAX_OBJECTIVE_CHARS = 40
|
|
| 8 |
export function goalStatusColor(status: GoalStatus): string {
|
| 9 |
switch (status) {
|
| 10 |
case 'pursuing':
|
| 11 |
-
return 'yellow'
|
| 12 |
case 'paused':
|
| 13 |
-
return 'gray'
|
| 14 |
case 'achieved':
|
| 15 |
-
return 'green'
|
| 16 |
case 'blocked':
|
| 17 |
-
return 'red'
|
| 18 |
case 'usage-limited':
|
| 19 |
-
return 'gray'
|
| 20 |
case 'budget-limited':
|
| 21 |
-
return 'magenta'
|
| 22 |
}
|
| 23 |
}
|
| 24 |
|
|
|
|
| 8 |
export function goalStatusColor(status: GoalStatus): string {
|
| 9 |
switch (status) {
|
| 10 |
case 'pursuing':
|
| 11 |
+
return 'ansi:yellow'
|
| 12 |
case 'paused':
|
| 13 |
+
return 'ansi:gray'
|
| 14 |
case 'achieved':
|
| 15 |
+
return 'ansi:green'
|
| 16 |
case 'blocked':
|
| 17 |
+
return 'ansi:red'
|
| 18 |
case 'usage-limited':
|
| 19 |
+
return 'ansi:gray'
|
| 20 |
case 'budget-limited':
|
| 21 |
+
return 'ansi:magenta'
|
| 22 |
}
|
| 23 |
}
|
| 24 |
|
|
@@ -26,7 +26,7 @@ import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js';
|
|
| 26 |
import { useAppState, useAppStateStore } from 'src/state/AppState.js';
|
| 27 |
import { getIsRemoteMode } from '../../bootstrap/state.js';
|
| 28 |
import HistorySearchInput from './HistorySearchInput.js';
|
| 29 |
-
import {
|
| 30 |
import { usePrStatus } from '../../hooks/usePrStatus.js';
|
| 31 |
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js';
|
| 32 |
import { Byline } from '../design-system/Byline.js';
|
|
@@ -261,6 +261,8 @@ function ModeIndicator({
|
|
| 261 |
const expandedView = useAppState(s_3 => s_3.expandedView);
|
| 262 |
const showSpinnerTree = expandedView === 'teammates';
|
| 263 |
const prStatus = usePrStatus(isLoading, isPrStatusEnabled());
|
|
|
|
|
|
|
| 264 |
const hasTmuxSession = useAppState(s_4 => "external" === 'ant' && s_4.tungstenActiveSession !== undefined);
|
| 265 |
const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL);
|
| 266 |
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
@@ -355,6 +357,19 @@ function ModeIndicator({
|
|
| 355 |
</Text>}
|
| 356 |
</Text> : null;
|
| 357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
// Build parts array - exclude BackgroundTaskStatus when we have teammate pills
|
| 359 |
// (teammate pills get their own row)
|
| 360 |
const parts = [
|
|
@@ -366,18 +381,18 @@ function ModeIndicator({
|
|
| 366 |
// its click-target Box isn't nested inside the <Text wrap="truncate">
|
| 367 |
// wrapper (reconciler throws on Box-in-Text).
|
| 368 |
// Tmux pill (ant-only) — appears right after tasks in nav order
|
| 369 |
-
...("external" === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []),
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
const goalPart = <GoalIndicator key="goal" />;
|
| 374 |
|
| 375 |
// Check if any in-process teammates exist (for hint text cycling)
|
| 376 |
-
const hasAnyInProcessTeammates = Object.values(tasks).some(
|
| 377 |
-
const hasRunningAgentTasks = Object.values(tasks).some(
|
| 378 |
|
| 379 |
// Get hint parts separately for potential second-line rendering
|
| 380 |
const hintParts = showHint ? getSpinnerHintParts(isLoading, escShortcut, todosShortcut, killAgentsShortcut, hasTaskItems, expandedView, hasAnyInProcessTeammates, hasRunningAgentTasks, isKillAgentsConfirmShowing) : [];
|
|
|
|
| 381 |
if (isViewingCompletedTeammate) {
|
| 382 |
parts.push(<Text dimColor key="esc-return">
|
| 383 |
<KeyboardShortcutHint shortcut={escShortcut} action="return to team lead" />
|
|
@@ -388,22 +403,6 @@ function ModeIndicator({
|
|
| 388 |
parts.push(...hintParts);
|
| 389 |
}
|
| 390 |
|
| 391 |
-
// When we have teammate pills, always render them on their own line above other parts
|
| 392 |
-
if (hasTeammatePills) {
|
| 393 |
-
// Don't append spinner hints when viewing a completed teammate —
|
| 394 |
-
// the "esc to return to team lead" hint already replaces "esc to interrupt"
|
| 395 |
-
const otherParts = [...(modePart ? [modePart] : []), ...parts, ...(isViewingCompletedTeammate ? [] : hintParts)];
|
| 396 |
-
return <Box flexDirection="column">
|
| 397 |
-
<Box>
|
| 398 |
-
<BackgroundTaskStatus tasksSelected={tasksSelected} isViewingTeammate={isViewingTeammate} teammateFooterIndex={teammateFooterIndex} isLeaderIdle={!isLoading} onOpenDialog={onOpenTasksDialog} />
|
| 399 |
-
</Box>
|
| 400 |
-
{otherParts.length > 0 && <Box>
|
| 401 |
-
<Byline>{otherParts}</Byline>
|
| 402 |
-
</Box>}
|
| 403 |
-
{goalPart && <Box><Byline>{goalPart}</Byline></Box>}
|
| 404 |
-
</Box>;
|
| 405 |
-
}
|
| 406 |
-
|
| 407 |
// Add "↓ to manage tasks" hint when panel has visible rows
|
| 408 |
const hasCoordinatorTasks = "external" === 'ant' && getVisibleAgentTasks(tasks).length > 0;
|
| 409 |
|
|
@@ -468,7 +467,7 @@ function ModeIndicator({
|
|
| 468 |
// from 0→1 row. Always render 1 row in fullscreen; return a space when
|
| 469 |
// empty so Yoga reserves the row without painting anything visible.
|
| 470 |
const hasMainRowContent = modePart || tasksPart || parts.length > 0;
|
| 471 |
-
if (!hasMainRowContent && !
|
| 472 |
return isFullscreenEnvEnabled() ? <Text> </Text> : null;
|
| 473 |
}
|
| 474 |
// When there IS a goal but no other main content, we still need to render
|
|
@@ -483,19 +482,19 @@ function ModeIndicator({
|
|
| 483 |
<Box height={1} overflow="hidden">
|
| 484 |
{modePart && <Box flexShrink={0}>
|
| 485 |
{modePart}
|
| 486 |
-
{(tasksPart || parts.length > 0 ||
|
| 487 |
</Box>}
|
| 488 |
{tasksPart && <Box flexShrink={0}>
|
| 489 |
{tasksPart}
|
| 490 |
-
{(parts.length > 0 ||
|
| 491 |
</Box>}
|
| 492 |
{parts.length > 0 && <Text wrap="truncate">
|
| 493 |
<Byline>{parts}</Byline>
|
| 494 |
</Text>}
|
| 495 |
-
{parts.length === 0 && !tasksPart && !modePart && !
|
| 496 |
-
{parts.length === 0 && !tasksPart && !modePart && !
|
| 497 |
</Box>
|
| 498 |
-
{
|
| 499 |
</Box>;
|
| 500 |
}
|
| 501 |
function getSpinnerHintParts(isLoading: boolean, escShortcut: string, todosShortcut: string, killAgentsShortcut: string, hasTaskItems: boolean, expandedView: 'none' | 'tasks' | 'teammates', hasTeammates: boolean, hasRunningAgentTasks: boolean, isKillAgentsConfirmShowing: boolean): React.ReactElement[] {
|
|
|
|
| 26 |
import { useAppState, useAppStateStore } from 'src/state/AppState.js';
|
| 27 |
import { getIsRemoteMode } from '../../bootstrap/state.js';
|
| 28 |
import HistorySearchInput from './HistorySearchInput.js';
|
| 29 |
+
import { goalStatusColor } from './GoalIndicator.js';
|
| 30 |
import { usePrStatus } from '../../hooks/usePrStatus.js';
|
| 31 |
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js';
|
| 32 |
import { Byline } from '../design-system/Byline.js';
|
|
|
|
| 261 |
const expandedView = useAppState(s_3 => s_3.expandedView);
|
| 262 |
const showSpinnerTree = expandedView === 'teammates';
|
| 263 |
const prStatus = usePrStatus(isLoading, isPrStatusEnabled());
|
| 264 |
+
const goal = useAppState(s => s.goal);
|
| 265 |
+
const goalMode = useAppState(s => s.toolPermissionContext.mode);
|
| 266 |
const hasTmuxSession = useAppState(s_4 => "external" === 'ant' && s_4.tungstenActiveSession !== undefined);
|
| 267 |
const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL);
|
| 268 |
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
|
|
| 357 |
</Text>}
|
| 358 |
</Text> : null;
|
| 359 |
|
| 360 |
+
// Inline goal indicator — reads goal state directly to avoid React compiler
|
| 361 |
+
// caching the dot color across status changes.
|
| 362 |
+
const GOAL_MAX_CHARS = 40;
|
| 363 |
+
const goalTruncated = goal && goal.objective.length > GOAL_MAX_CHARS
|
| 364 |
+
? goal.objective.slice(0, GOAL_MAX_CHARS - 1) + '…'
|
| 365 |
+
: goal?.objective;
|
| 366 |
+
const goalPlanSuppressed = goal?.status === 'pursuing' && goalMode === 'plan';
|
| 367 |
+
const goalLabel = goalPlanSuppressed ? 'paused: plan mode' : goal?.status;
|
| 368 |
+
const goalDotColor = goalPlanSuppressed ? 'yellow' : goal ? goalStatusColor(goal.status) : undefined;
|
| 369 |
+
const goalInline = goal
|
| 370 |
+
? <Text><Text color={goalDotColor}>●</Text><Text dimColor> goal: </Text><Text>{goalTruncated}</Text><Text dimColor> [{goalLabel}]</Text></Text>
|
| 371 |
+
: null;
|
| 372 |
+
|
| 373 |
// Build parts array - exclude BackgroundTaskStatus when we have teammate pills
|
| 374 |
// (teammate pills get their own row)
|
| 375 |
const parts = [
|
|
|
|
| 381 |
// its click-target Box isn't nested inside the <Text wrap="truncate">
|
| 382 |
// wrapper (reconciler throws on Box-in-Text).
|
| 383 |
// Tmux pill (ant-only) — appears right after tasks in nav order
|
| 384 |
+
...("external" === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []),
|
| 385 |
+
...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []),
|
| 386 |
+
...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number} url={prStatus.url} reviewState={prStatus.reviewState!} />] : []),
|
| 387 |
+
]
|
|
|
|
| 388 |
|
| 389 |
// Check if any in-process teammates exist (for hint text cycling)
|
| 390 |
+
const hasAnyInProcessTeammates = Object.values(tasks).some(t => t.type === 'in_process_teammate' && t.status === 'running');
|
| 391 |
+
const hasRunningAgentTasks = Object.values(tasks).some(t => t.type === 'local_agent' && t.status === 'running');
|
| 392 |
|
| 393 |
// Get hint parts separately for potential second-line rendering
|
| 394 |
const hintParts = showHint ? getSpinnerHintParts(isLoading, escShortcut, todosShortcut, killAgentsShortcut, hasTaskItems, expandedView, hasAnyInProcessTeammates, hasRunningAgentTasks, isKillAgentsConfirmShowing) : [];
|
| 395 |
+
|
| 396 |
if (isViewingCompletedTeammate) {
|
| 397 |
parts.push(<Text dimColor key="esc-return">
|
| 398 |
<KeyboardShortcutHint shortcut={escShortcut} action="return to team lead" />
|
|
|
|
| 403 |
parts.push(...hintParts);
|
| 404 |
}
|
| 405 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
// Add "↓ to manage tasks" hint when panel has visible rows
|
| 407 |
const hasCoordinatorTasks = "external" === 'ant' && getVisibleAgentTasks(tasks).length > 0;
|
| 408 |
|
|
|
|
| 467 |
// from 0→1 row. Always render 1 row in fullscreen; return a space when
|
| 468 |
// empty so Yoga reserves the row without painting anything visible.
|
| 469 |
const hasMainRowContent = modePart || tasksPart || parts.length > 0;
|
| 470 |
+
if (!hasMainRowContent && !goalInline) {
|
| 471 |
return isFullscreenEnvEnabled() ? <Text> </Text> : null;
|
| 472 |
}
|
| 473 |
// When there IS a goal but no other main content, we still need to render
|
|
|
|
| 482 |
<Box height={1} overflow="hidden">
|
| 483 |
{modePart && <Box flexShrink={0}>
|
| 484 |
{modePart}
|
| 485 |
+
{(tasksPart || parts.length > 0 || goalInline) && <Text dimColor> · </Text>}
|
| 486 |
</Box>}
|
| 487 |
{tasksPart && <Box flexShrink={0}>
|
| 488 |
{tasksPart}
|
| 489 |
+
{(parts.length > 0 || goalInline) && <Text dimColor> · </Text>}
|
| 490 |
</Box>}
|
| 491 |
{parts.length > 0 && <Text wrap="truncate">
|
| 492 |
<Byline>{parts}</Byline>
|
| 493 |
</Text>}
|
| 494 |
+
{parts.length === 0 && !tasksPart && !modePart && !goalInline && isFullscreenEnvEnabled() && <Text> </Text>}
|
| 495 |
+
{parts.length === 0 && !tasksPart && !modePart && !goalInline && !isFullscreenEnvEnabled() && null}
|
| 496 |
</Box>
|
| 497 |
+
{goalInline && <Box height={1}><Byline>{goalInline}</Byline></Box>}
|
| 498 |
</Box>;
|
| 499 |
}
|
| 500 |
function getSpinnerHintParts(isLoading: boolean, escShortcut: string, todosShortcut: string, killAgentsShortcut: string, hasTaskItems: boolean, expandedView: 'none' | 'tasks' | 'teammates', hasTeammates: boolean, hasRunningAgentTasks: boolean, isKillAgentsConfirmShowing: boolean): React.ReactElement[] {
|