Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
24 changes: 24 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export enum Command {
CLEAR_SCREEN = 'app.clearScreen',
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',

// Task tree collapse/expand
TOGGLE_COLLAPSE = 'tree.toggleCollapse',
COLLAPSE_ALL = 'tree.collapseAll',
EXPAND_ALL = 'tree.expandAll',
}

/**
Expand Down Expand Up @@ -257,6 +262,11 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }, { key: 'r', shift: true }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],

// Task tree collapse/expand
[Command.TOGGLE_COLLAPSE]: [{ key: 'right' }, { key: 'left' }],
[Command.COLLAPSE_ALL]: [{ key: '[', ctrl: true }],
[Command.EXPAND_ALL]: [{ key: ']', ctrl: true }],
};

interface CommandCategory {
Expand Down Expand Up @@ -379,6 +389,14 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SUSPEND_APP,
],
},
{
title: 'Task Tree',
commands: [
Command.TOGGLE_COLLAPSE,
Command.COLLAPSE_ALL,
Command.EXPAND_ALL,
],
},
];

/**
Expand Down Expand Up @@ -485,4 +503,10 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',

// Task tree collapse/expand
[Command.TOGGLE_COLLAPSE]:
'Toggle the expanded/collapsed state of the focused task tree node.',
[Command.COLLAPSE_ALL]: 'Collapse all tool output sections in the task tree.',
[Command.EXPAND_ALL]: 'Expand all tool output sections in the task tree.',
};
12 changes: 12 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ interface HistoryItemDisplayProps {
isExpandable?: boolean;
isFirstThinking?: boolean;
isFirstAfterThinking?: boolean;
/**
* When true and item is tool_group, render nothing. Used so the task tree
* can be the single source of truth for the most recent tool run (no duplicate
* linear "thought box" above the tree).
*/
suppressToolGroup?: boolean;
}

export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
Expand All @@ -60,6 +66,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isExpandable,
isFirstThinking = false,
isFirstAfterThinking = false,
suppressToolGroup = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
Expand All @@ -68,6 +75,11 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
const needsTopMarginAfterThinking =
isFirstAfterThinking && inlineThinkingMode !== 'off';

// Task tree shows the most recent tool run; avoid duplicate linear box.
if (itemForDisplay.type === 'tool_group' && suppressToolGroup) {
return <Box key={itemForDisplay.id} width={terminalWidth} />;
}

return (
<Box
flexDirection="column"
Expand Down
121 changes: 73 additions & 48 deletions packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { TaskTree } from './TaskTree.js';
import { useTaskTree } from '../hooks/useTaskTree.js';
import type { IndividualToolCallDisplay } from '../types.js';
import { escapeAnsiCtrlCodes } from '../utils/textUtils.js';

const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
Expand All @@ -36,22 +40,8 @@ export const MainContent = () => {
const showConfirmationQueue = confirmingTool !== null;
const confirmingToolCallId = confirmingTool?.tool.callId;

const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);

useEffect(() => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingToolCallId]);

const {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
cleanUiDetailsVisible,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;

// Accumulate all tool calls for the current agent turn so the tree persists
// across round-trips
const lastUserPromptIndex = useMemo(() => {
for (let i = uiState.history.length - 1; i >= 0; i--) {
const type = uiState.history[i].type;
Expand All @@ -62,6 +52,38 @@ export const MainContent = () => {
return -1;
}, [uiState.history]);

const allCurrentTurnToolCalls = useMemo<IndividualToolCallDisplay[]>(() => {
const calls: IndividualToolCallDisplay[] = [];
// Completed batches already committed to history for this turn
for (let i = lastUserPromptIndex + 1; i < uiState.history.length; i++) {
const item = uiState.history[i];
if (item.type === 'tool_group') {
calls.push(...item.tools.map(escapeAnsiCtrlCodes));
}
}
// Live batch still pending
for (const item of uiState.pendingHistoryItems) {
if (item.type === 'tool_group') {
calls.push(...item.tools.map(escapeAnsiCtrlCodes));
}
}
return calls;
}, [uiState.history, uiState.pendingHistoryItems, lastUserPromptIndex]);

const taskTree = useTaskTree(allCurrentTurnToolCalls);

const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);

useEffect(() => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingToolCallId]);

const { mainAreaWidth, staticAreaMaxItemHeight, cleanUiDetailsVisible } =
uiState;
const showHeaderDetails = cleanUiDetailsVisible;

const augmentedHistory = useMemo(
() =>
uiState.history.map((item, index) => {
Expand All @@ -72,21 +94,34 @@ export const MainContent = () => {
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
// Suppress all tool_group items from the current turn when the task tree
// is active — the tree is the single source of truth for the full turn.
const suppressToolGroup =
item.type === 'tool_group' &&
index > lastUserPromptIndex &&
taskTree.hasHierarchy;

return {
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressToolGroup,
};
}),
[uiState.history, lastUserPromptIndex],
[uiState.history, lastUserPromptIndex, taskTree.hasHierarchy],
);

const historyItems = useMemo(
() =>
augmentedHistory.map(
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => (
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressToolGroup,
}) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
Expand All @@ -102,6 +137,7 @@ export const MainContent = () => {
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressToolGroup={suppressToolGroup}
/>
),
),
Expand All @@ -127,57 +163,45 @@ export const MainContent = () => {
const pendingItems = useMemo(
() => (
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => {
const prevType =
i === 0
? uiState.history.at(-1)?.type
: pendingHistoryItems[i - 1]?.type;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';

return (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isExpandable={true}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
/>
);
})}
{/* display task tree */}
{taskTree.hasHierarchy && (
<TaskTree
{...taskTree}
terminalWidth={mainAreaWidth}
isFocused={!uiState.embeddedShellFocused}
/>
)}
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue confirmingTool={confirmingTool} />
)}
</Box>
),
[
pendingHistoryItems,
uiState.constrainHeight,
staticAreaMaxItemHeight,
uiState.embeddedShellFocused,
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
uiState.history,
taskTree,
],
);

const virtualizedData = useMemo(
() => [
{ type: 'header' as const },
...augmentedHistory.map(
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressToolGroup,
}) => ({
type: 'history' as const,
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressToolGroup,
}),
),
{ type: 'pending' as const },
Expand Down Expand Up @@ -212,6 +236,7 @@ export const MainContent = () => {
isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking}
isFirstAfterThinking={item.isFirstAfterThinking}
suppressToolGroup={item.suppressToolGroup}
/>
);
} else {
Expand Down
Loading