Skip to content
Open
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
45 changes: 14 additions & 31 deletions src/components/PromptInput/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import { findSlackChannelPositions, getKnownChannelsVersion, hasSlackMcpServer,
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js';
import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js';
import type { TeamSummary } from '../../utils/teamDiscovery.js';
import { getTeammateColor } from '../../utils/teammate.js';
import { getTeammateColor, getTeamName } from '../../utils/teammate.js';
import { isInProcessTeammate } from '../../utils/teammateContext.js';
import { writeToMailbox } from '../../utils/teammateMailbox.js';
import type { TextHighlight } from '../../utils/textHighlighting.js';
Expand Down Expand Up @@ -123,7 +123,7 @@ import { useMaybeTruncateInput } from './useMaybeTruncateInput.js';
import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js';
import { useShowFastIconHint } from './useShowFastIconHint.js';
import { useSwarmBanner } from './useSwarmBanner.js';
import { canAcceptPromptSuggestion, isVimModeEnabled, normalizePromptInputChunk, resolveCoalescedModeSubmission, resolveHelpToggleChange } from './utils.js';
import { canAcceptPromptSuggestion, isVimModeEnabled, normalizePromptInputChunk, resolveCoalescedModeSubmission, resolveHelpToggleChange, resolvePromptBorderColor } from './utils.js';
type Props = {
debug: boolean;
ideSelection: IDESelection | undefined;
Expand Down Expand Up @@ -198,6 +198,7 @@ type Props = {
// Bottom slot has maxHeight="50%"; reserve lines for footer, border, status.
const PROMPT_FOOTER_LINES = 5;
const MIN_INPUT_VIEWPORT_LINES = 3;
/** Renders the prompt editor, agent banner, and mode-aware input controls. */
function PromptInput({
debug,
ideSelection,
Expand Down Expand Up @@ -314,6 +315,7 @@ function PromptInput({
// WebBrowser pill — visible when a browser is open
const bagelFooterVisible = useAppState(s => false);
const teamContext = useAppState(s => s.teamContext);
const standaloneAgentContext = useAppState(s => s.standaloneAgentContext);
const queuedCommands = useCommandQueue();
const promptSuggestionState = useAppState(s => s.promptSuggestion);
const speculation = useAppState(s => s.speculation);
Expand Down Expand Up @@ -2288,35 +2290,16 @@ function PromptInput({
inlineGhostText,
inputFilter: lazySpaceInputFilter
};
const getBorderColor = (): keyof Theme => {
const modeColors: Record<string, keyof Theme> = {
bash: 'bashBorder'
};

// Mode colors take priority, then teammate color, then default
if (modeColors[mode]) {
return modeColors[mode];
}

// In-process teammates run headless - don't apply teammate colors to leader UI
if (isInProcessTeammate()) {
return 'promptBorder';
}

// Check for teammate color from environment
const teammateColorName = getTeammateColor();
if (teammateColorName && AGENT_COLORS.includes(teammateColorName as AgentColorName)) {
return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName];
}

// Ambient ultracode indicator: cyan-blue border whenever ultracode is the
// active effort. Ranks below bash mode and teammate identity (explicit
// contextual overrides) but above the default border.
if (ultracodeActive) {
return 'ultracode';
}
return 'promptBorder';
};
/** Resolves the current mode and team/standalone identity for the input border. */
const getBorderColor = (): keyof Theme => resolvePromptBorderColor({
mode,
inProcessTeammate: isInProcessTeammate(),
teammateColor: getTeammateColor(),
teamContext,
teamName: getTeamName(teamContext),
standaloneColor: standaloneAgentContext?.color,
ultracodeActive,
});
if (isExternalEditorActive) {
return <Box flexDirection="row" alignItems="center" justifyContent="center" borderColor={getBorderColor()} borderStyle="round" borderLeft={false} borderRight={false} borderBottom width="100%">
<Text dimColor italic>
Expand Down
15 changes: 10 additions & 5 deletions src/components/PromptInput/useSwarmBanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
isTeammate,
} from '../../utils/teammate.js'
import { isInProcessTeammate } from '../../utils/teammateContext.js'
import { shouldShowStandaloneAgentBanner } from './utils.js'
import type { Theme } from '../../utils/theme.js'

type SwarmBannerInfo = {
Expand All @@ -34,8 +35,8 @@ type SwarmBannerInfo = {
/**
* Hook that returns banner information for swarm, standalone agent, or --agent CLI context.
* - Leader (not in tmux): Returns "tmux -L ... attach" command with cyan background
* - Leader (in tmux / in-process): Falls through to standalone-agent check — shows
* /rename name + /color background if set, else null
* - Leader (in tmux / in-process): Shows the viewed teammate, otherwise suppresses
* saved standalone identity while the team remains active
* - Teammate: Returns "teammate@team" format with their assigned color background
* - Viewing a background agent (CoordinatorTaskPanel): Returns agent name with its color
* - Standalone agent: Returns agent name with their color background (no @team)
Expand Down Expand Up @@ -99,7 +100,7 @@ export function useSwarmBanner(): SwarmBannerInfo {
}
}
// insideTmux === null: still loading — fall through.
// Not viewing a teammate: fall through so /rename and /color are honored.
// Not viewing a teammate: fall through to background-agent and CLI checks.
}

// Viewing a background agent (CoordinatorTaskPanel): local_agent tasks aren't
Expand All @@ -121,10 +122,13 @@ export function useSwarmBanner(): SwarmBannerInfo {
}
}

// Standalone agent (/rename, /color): name and/or custom color, no @team.
// Standalone agent (/rename, /color): a usable name outside any active team.
const standaloneName = getStandaloneAgentName(state)
const standaloneColor = standaloneAgentContext?.color
if (standaloneName || standaloneColor) {
if (
!getTeamName(teamContext) &&
shouldShowStandaloneAgentBanner(standaloneName)
) {
return {
text: standaloneName ?? '',
bgColor: toThemeColor(standaloneColor),
Expand All @@ -145,6 +149,7 @@ export function useSwarmBanner(): SwarmBannerInfo {
return null
}

/** Maps a recognized agent color to its theme token, otherwise uses the fallback. */
function toThemeColor(
colorName: string | undefined,
fallback: keyof Theme = 'cyan_FOR_SUBAGENTS_ONLY',
Expand Down
133 changes: 132 additions & 1 deletion src/components/PromptInput/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { PassThrough } from 'node:stream'

import { expect, test } from 'bun:test'
import React from 'react'

import type { Key } from '../../ink.js'
import { createRoot, type Key } from '../../ink.js'
import { AppStateProvider, getDefaultAppState } from '../../state/AppState.js'
import { useSwarmBanner } from './useSwarmBanner.js'
import {
canAcceptPromptSuggestion,
isNonSpacePrintable,
normalizePromptInputChunk,
shouldShowStandaloneAgentBanner,
resolvePromptBorderColor,
resolveHelpToggleChange,
resolveCoalescedModeSubmission,
} from './utils.js'
Expand Down Expand Up @@ -86,6 +93,130 @@ test('preserves input and rendered mode without a pending mode entry', () => {
})
})

test('does not create a banner for a color-only standalone context', () => {
expect(shouldShowStandaloneAgentBanner(undefined)).toBe(false)
expect(shouldShowStandaloneAgentBanner('')).toBe(false)
expect(shouldShowStandaloneAgentBanner(' ')).toBe(false)
})

test('creates a banner when a standalone agent has a usable name', () => {
expect(shouldShowStandaloneAgentBanner('renato')).toBe(true)
})

Comment thread
coderabbitai[bot] marked this conversation as resolved.
test.each([
{ name: '', teamName: undefined, expected: null },
{ name: ' ', teamName: undefined, expected: null },
{ name: 'saved-agent', teamName: 'active-team', expected: null },
{
name: 'saved-agent', teamName: undefined,
expected: { text: 'saved-agent', bgColor: 'blue_FOR_SUBAGENTS_ONLY' },
},
])('useSwarmBanner respects standalone and AppState team identity: %j', async ({ name, teamName, expected }) => {
let observedBanner: ReturnType<typeof useSwarmBanner> | undefined
let notifyRendered!: () => void
const rendered = new Promise<void>(resolve => { notifyRendered = resolve })

/** Observes the real hook after mounting its AppState provider. */
function HookProbe() {
observedBanner = useSwarmBanner()
React.useEffect(() => { notifyRendered() }, [])
return null
}

const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120

const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
const exited = root.waitUntilExit()

try {
root.render(
React.createElement(AppStateProvider, {
initialState: {
...getDefaultAppState(),
standaloneAgentContext: { name, color: 'blue' },
teamContext: teamName ? {
teamName,
teamFilePath: '/test/team.json',
leadAgentId: 'team-lead',
isLeader: true,
selfAgentColor: 'red',
teammates: {},
} : undefined,
},
children: React.createElement(HookProbe),
}),
)

await rendered
expect(observedBanner).toEqual(expected)
} finally {
root.unmount()
await exited
stdout.destroy()
stdin.destroy()
}
})

test('standalone border color respects mode and team identity', () => {
const standalone = {
mode: 'prompt' as const,
inProcessTeammate: false,
standaloneColor: 'blue',
ultracodeActive: false,
}
expect(resolvePromptBorderColor(standalone)).toBe('blue_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...standalone, mode: 'bash' })).toBe('bashBorder')
expect(resolvePromptBorderColor({ ...standalone, inProcessTeammate: true })).toBe('promptBorder')
expect(resolvePromptBorderColor({ ...standalone, teammateColor: 'red' })).toBe('red_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...standalone, teammateColor: 'invalid' })).toBe('blue_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...standalone, teamName: 'team', teammateColor: 'red' })).toBe('red_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...standalone, teamName: 'team' })).toBe('promptBorder')
expect(resolvePromptBorderColor({ ...standalone, teamName: 'team', ultracodeActive: true })).toBe('ultracode')
expect(resolvePromptBorderColor({ ...standalone, standaloneColor: 'invalid' })).toBe('promptBorder')
expect(resolvePromptBorderColor({ ...standalone, standaloneColor: undefined, ultracodeActive: true })).toBe('ultracode')
expect(resolvePromptBorderColor({ ...standalone, ultracodeActive: true })).toBe('blue_FOR_SUBAGENTS_ONLY')
})

test('border color reads production AppState member identity before dynamic fallback', () => {
const leader = {
name: 'team-lead', color: 'red', tmuxSessionName: '', tmuxPaneId: '',
cwd: '/test', spawnedAt: 0,
}
const teamContext = {
teamName: 'active-team', teamFilePath: '/test/team.json', leadAgentId: 'leader',
teammates: { leader, member: { ...leader, name: 'member', color: 'green' } },
}
const input = {
mode: 'prompt' as const, inProcessTeammate: false, teamContext,
standaloneColor: 'blue', ultracodeActive: true,
}
// TeamCreateTool stores the leader color in teammates, without selfAgentColor.
expect(resolvePromptBorderColor(input)).toBe('red_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...input, teammateColor: 'yellow' })).toBe('red_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...input, teamContext: { ...teamContext, selfAgentId: 'member' } })).toBe('green_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...input, teamContext: { ...teamContext, selfAgentColor: 'purple' } })).toBe('purple_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...input, teamContext: { ...teamContext, selfAgentId: 'missing' }, teammateColor: 'yellow' })).toBe('yellow_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...input, teamContext: { ...teamContext, teammates: {} } })).toBe('ultracode')
expect(resolvePromptBorderColor({ ...input, teamContext: { ...teamContext, teammates: {}, selfAgentColor: 'invalid' }, teammateColor: 'yellow' })).toBe('yellow_FOR_SUBAGENTS_ONLY')
expect(resolvePromptBorderColor({ ...input, mode: 'bash' })).toBe('bashBorder')
expect(resolvePromptBorderColor({ ...input, inProcessTeammate: true })).toBe('promptBorder')
})

test('only prompt submissions can accept prompt suggestions', () => {
expect(canAcceptPromptSuggestion('prompt')).toBe(true)
expect(canAcceptPromptSuggestion('bash')).toBe(false)
Expand Down
61 changes: 61 additions & 0 deletions src/components/PromptInput/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import {
isShiftEnterKeyBindingInstalled,
} from '../../commands/terminalSetup/terminalSetup.js'
import type { Key } from '../../ink.js'
import type { AppState } from '../../state/AppState.js'
import {
AGENT_COLORS,
AGENT_COLOR_TO_THEME_COLOR,
type AgentColorName,
} from '../../tools/AgentTool/agentColorManager.js'
import type { Theme } from '../../utils/theme.js'
import type {
PromptInputMode,
TextInputChangeContext,
Expand Down Expand Up @@ -126,3 +133,57 @@ export function resolveCoalescedModeSubmission(
export function canAcceptPromptSuggestion(mode: PromptInputMode): boolean {
return mode === 'prompt'
}

/**
* A standalone color without a usable name should tint the prompt border,
* not create an empty banner that changes the prompt layout.
*/
export function shouldShowStandaloneAgentBanner(
standaloneName: string | undefined,
): boolean {
return standaloneName !== undefined && standaloneName.trim().length > 0
}

/**
* Resolves the border token with mode overrides before agent identity.
* Active teams suppress saved standalone colors; invalid colors fall through
* to the next eligible identity, then ultracode or the default prompt border.
* AppState color priority is explicit self color, matching member color, then
* dynamic teammate color. Leaders without a self ID use their leadAgentId.
*/
export function resolvePromptBorderColor({
mode,
inProcessTeammate,
teammateColor,
teamContext,
teamName,
standaloneColor,
ultracodeActive,
}: {
mode: PromptInputMode
inProcessTeammate: boolean
teammateColor?: string
teamContext?: AppState['teamContext']
teamName?: string
standaloneColor?: string
ultracodeActive?: boolean
}): keyof Theme {
if (mode === 'bash') return 'bashBorder'
if (inProcessTeammate) return 'promptBorder'

const memberId = teamContext?.selfAgentId ?? teamContext?.leadAgentId
const memberColor = memberId ? teamContext?.teammates[memberId]?.color : undefined

// Team identity takes precedence over a saved standalone color.
for (const identityColor of [
teamContext?.selfAgentColor,
memberColor,
teammateColor,
teamName || teamContext?.teamName ? undefined : standaloneColor,
]) {
if (identityColor && AGENT_COLORS.includes(identityColor as AgentColorName)) {
return AGENT_COLOR_TO_THEME_COLOR[identityColor as AgentColorName]
}
}
return ultracodeActive ? 'ultracode' : 'promptBorder'
}