Repository: google-gemini/gemini-cli (TypeScript)

Issue:
Hide collapsed Todo tray when all items are resolved/cancelled
### What happened?

Someone ( believe @joshualitt) said they saw the todo tray still taking up a whole line when toggled down even though all items were competed/cancelled.

### What did you expect to happen?

The toggled closed tray view should be invisible if there are no pending items.

### Client information

<details>
<summary>Client Information</summary>

Run `gemini` to enter the interactive CLI, then run the `/about` command.

```console
> /about
# paste output here
```

</details>

### Login information

_No response_

### Anything else we need to know?

_No response_


Discussion:
I was able to reproduce this.



Patch:
diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx
index 73ba5289720..c558e0a43aa 100644
--- a/packages/cli/src/ui/components/messages/Todo.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.tsx
@@ -117,7 +117,19 @@ export const TodoTray: React.FC = () => {
     return todos.todos.find((todo) => todo.status === 'in_progress') || null;
   }, [todos]);
 
-  if (todos === null || !todos.todos || todos.todos.length === 0) {
+  const hasActiveTodos = useMemo(() => {
+    if (!todos || !todos.todos) return false;
+    return todos.todos.some(
+      (todo) => todo.status === 'pending' || todo.status === 'in_progress',
+    );
+  }, [todos]);
+
+  if (
+    todos === null ||
+    !todos.todos ||
+    todos.todos.length === 0 ||
+    (!uiState.showFullTodos && !hasActiveTodos)
+  ) {
     return null;
   }
 
@@ -134,7 +146,7 @@ export const TodoTray: React.FC = () => {
       {uiState.showFullTodos ? (
         <Box flexDirection="column" rowGap={1}>
           <TodoTitleDisplay todos={todos} />
-          <TodoListDisplay todos={todos!} />
+          <TodoListDisplay todos={todos} />
         </Box>
       ) : (
         <Box flexDirection="row" columnGap={1} height={1}>
@@ -143,7 +155,7 @@ export const TodoTray: React.FC = () => {
           </Box>
           {inProgress && (
             <Box flexShrink={1} flexGrow={1}>
-              <TodoItemDisplay todo={inProgress!} wrap="truncate" />
+              <TodoItemDisplay todo={inProgress} wrap="truncate" />
             </Box>
           )}
         </Box>


Test patch:
diff --git a/packages/cli/src/ui/components/messages/Todo.test.tsx b/packages/cli/src/ui/components/messages/Todo.test.tsx
index 14ace3b3b76..bae8f694fe8 100644
--- a/packages/cli/src/ui/components/messages/Todo.test.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.test.tsx
@@ -30,36 +30,30 @@ const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
     ],
   }) as unknown as HistoryItem;
 
-describe('<TodoTray />', () => {
-  const renderWithUiState = (uiState: Partial<UIState>) =>
-    render(
-      <UIStateContext.Provider value={uiState as UIState}>
-        <TodoTray />
-      </UIStateContext.Provider>,
-    );
+describe.each([true, false])(
+  '<TodoTray /> (showFullTodos: %s)',
+  (showFullTodos: boolean) => {
+    const renderWithUiState = (uiState: Partial<UIState>) =>
+      render(
+        <UIStateContext.Provider value={uiState as UIState}>
+          <TodoTray />
+        </UIStateContext.Provider>,
+      );
 
-  it.each([true, false])(
-    'renders null when no todos are in the history',
-    (showFullTodos) => {
+    it('renders null when no todos are in the history', () => {
       const { lastFrame } = renderWithUiState({ history: [], showFullTodos });
       expect(lastFrame()).toMatchSnapshot();
-    },
-  );
+    });
 
-  it.each([true, false])(
-    'renders null when todo list is empty',
-    (showFullTodos) => {
+    it('renders null when todo list is empty', () => {
       const { lastFrame } = renderWithUiState({
         history: [createTodoHistoryItem([])],
         showFullTodos,
       });
       expect(lastFrame()).toMatchSnapshot();
-    },
-  );
+    });
 
-  it.each([true, false])(
-    'renders when todos exist but none are in progress',
-    (showFullTodos) => {
+    it('renders when todos exist but none are in progress', () => {
       const { lastFrame } = renderWithUiState({
         history: [
           createTodoHistoryItem([
@@ -71,12 +65,9 @@ describe('<TodoTray />', () => {
         showFullTodos,
       });
       expect(lastFrame()).toMatchSnapshot();
-    },
-  );
+    });
 
-  it.each([true, false])(
-    'renders when todos exist and one is in progress',
-    (showFullTodos) => {
+    it('renders when todos exist and one is in progress', () => {
       const { lastFrame } = renderWithUiState({
         history: [
           createTodoHistoryItem([
@@ -89,12 +80,9 @@ describe('<TodoTray />', () => {
         showFullTodos,
       });
       expect(lastFrame()).toMatchSnapshot();
-    },
-  );
+    });
 
-  it.each([true, false])(
-    'renders a todo list with long descriptions that wrap when full view is on',
-    (showFullTodos) => {
+    it('renders a todo list with long descriptions that wrap when full view is on', () => {
       const { lastFrame } = render(
         <Box width="50">
           <UIStateContext.Provider
@@ -123,23 +111,36 @@ describe('<TodoTray />', () => {
         </Box>,
       );
       expect(lastFrame()).toMatchSnapshot();
-    },
-  );
+    });
+
+    it('renders the most recent todo list when multiple write_todos calls are in history', () => {
+      const { lastFrame } = renderWithUiState({
+        history: [
+          createTodoHistoryItem([
+            { description: 'Older Task 1', status: 'completed' },
+            { description: 'Older Task 2', status: 'pending' },
+          ]),
+          createTodoHistoryItem([
+            { description: 'Newer Task 1', status: 'pending' },
+            { description: 'Newer Task 2', status: 'in_progress' },
+          ]),
+        ],
+        showFullTodos,
+      });
+      expect(lastFrame()).toMatchSnapshot();
+    });
 
-  it('renders the most recent todo list when multiple write_todos calls are in history', () => {
-    const { lastFrame } = renderWithUiState({
-      history: [
-        createTodoHistoryItem([
-          { description: 'Older Task 1', status: 'completed' },
-          { description: 'Older Task 2', status: 'pending' },
-        ]),
-        createTodoHistoryItem([
-          { description: 'Newer Task 1', status: 'pending' },
-          { description: 'Newer Task 2', status: 'in_progress' },
-        ]),
-      ],
-      showFullTodos: true,
+    it('renders full list when all todos are inactive', () => {
+      const { lastFrame } = renderWithUiState({
+        history: [
+          createTodoHistoryItem([
+            { description: 'Task 1', status: 'completed' },
+            { description: 'Task 2', status: 'cancelled' },
+          ]),
+        ],
+        showFullTodos,
+      });
+      expect(lastFrame()).toMatchSnapshot();
     });
-    expect(lastFrame()).toMatchSnapshot();
-  });
-});
+  },
+);
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap
index 873bf4d57b4..47c202b81bc 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap
@@ -1,6 +1,32 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`<TodoTray /> > renders a todo list with long descriptions that wrap when full view is on 1`] = `
+exports[`<TodoTray /> (showFullTodos: false) > renders a todo list with long descriptions that wrap when full view is on 1`] = `
+"──────────────────────────────────────────────────
+ Todo  1/2 (ctrl+t to toggle) » This is a very l…"
+`;
+
+exports[`<TodoTray /> (showFullTodos: false) > renders full list when all todos are inactive 1`] = `""`;
+
+exports[`<TodoTray /> (showFullTodos: false) > renders null when no todos are in the history 1`] = `""`;
+
+exports[`<TodoTray /> (showFullTodos: false) > renders null when todo list is empty 1`] = `""`;
+
+exports[`<TodoTray /> (showFullTodos: false) > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Todo  0/2 (ctrl+t to toggle) » Newer Task 2"
+`;
+
+exports[`<TodoTray /> (showFullTodos: false) > renders when todos exist and one is in progress 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Todo  1/3 (ctrl+t to toggle) » Task 2"
+`;
+
+exports[`<TodoTray /> (showFullTodos: false) > renders when todos exist but none are in progress 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Todo  1/2 (ctrl+t to toggle)"
+`;
+
+exports[`<TodoTray /> (showFullTodos: true) > renders a todo list with long descriptions that wrap when full view is on 1`] = `
 "──────────────────────────────────────────────────
  Todo  1/2 (ctrl+t to toggle)
 
@@ -11,20 +37,19 @@ exports[`<TodoTray /> > renders a todo list with long descriptions that wrap whe
    description to test wrapping behavior."
 `;
 
-exports[`<TodoTray /> > renders a todo list with long descriptions that wrap when full view is on 2`] = `
-"──────────────────────────────────────────────────
- Todo  1/2 (ctrl+t to toggle) » This is a very l…"
-`;
-
-exports[`<TodoTray /> > renders null when no todos are in the history 1`] = `""`;
+exports[`<TodoTray /> (showFullTodos: true) > renders full list when all todos are inactive 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Todo  1/1 (ctrl+t to toggle)
 
-exports[`<TodoTray /> > renders null when no todos are in the history 2`] = `""`;
+ ✓ Task 1
+ ✗ Task 2"
+`;
 
-exports[`<TodoTray /> > renders null when todo list is empty 1`] = `""`;
+exports[`<TodoTray /> (showFullTodos: true) > renders null when no todos are in the history 1`] = `""`;
 
-exports[`<TodoTray /> > renders null when todo list is empty 2`] = `""`;
+exports[`<TodoTray /> (showFullTodos: true) > renders null when todo list is empty 1`] = `""`;
 
-exports[`<TodoTray /> > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
+exports[`<TodoTray /> (showFullTodos: true) > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
 "────────────────────────────────────────────────────────────────────────────────────────────────────
  Todo  0/2 (ctrl+t to toggle)
 
@@ -32,7 +57,7 @@ exports[`<TodoTray /> > renders the most recent todo list when multiple write_to
  » Newer Task 2"
 `;
 
-exports[`<TodoTray /> > renders when todos exist and one is in progress 1`] = `
+exports[`<TodoTray /> (showFullTodos: true) > renders when todos exist and one is in progress 1`] = `
 "────────────────────────────────────────────────────────────────────────────────────────────────────
  Todo  1/3 (ctrl+t to toggle)
 
@@ -42,12 +67,7 @@ exports[`<TodoTray /> > renders when todos exist and one is in progress 1`] = `
  ✓ Completed Task"
 `;
 
-exports[`<TodoTray /> > renders when todos exist and one is in progress 2`] = `
-"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo  1/3 (ctrl+t to toggle) » Task 2"
-`;
-
-exports[`<TodoTray /> > renders when todos exist but none are in progress 1`] = `
+exports[`<TodoTray /> (showFullTodos: true) > renders when todos exist but none are in progress 1`] = `
 "────────────────────────────────────────────────────────────────────────────────────────────────────
  Todo  1/2 (ctrl+t to toggle)
 
@@ -55,8 +75,3 @@ exports[`<TodoTray /> > renders when todos exist but none are in progress 1`] =
  ✗ In Progress Task
  ✓ Completed Task"
 `;
-
-exports[`<TodoTray /> > renders when todos exist but none are in progress 2`] = `
-"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo  1/2 (ctrl+t to toggle)"
-`;