diff --git a/.gemini/skills/docs-changelog/SKILL.md b/.gemini/skills/docs-changelog/SKILL.md index f175260abd..a0c0ad8600 100644 --- a/.gemini/skills/docs-changelog/SKILL.md +++ b/.gemini/skills/docs-changelog/SKILL.md @@ -162,5 +162,7 @@ instructions for the other section. ## Finalize -- After making changes, run `npm run format` ONLY to ensure consistency. +- After making changes, if `npm run format` fails, it may be necessary to run + `npm install` first to ensure all formatting dependencies are available. + Then, run `npm run format` to ensure consistency. - Delete any temporary files created during the process. diff --git a/.gemini/skills/docs-writer/SKILL.md b/.gemini/skills/docs-writer/SKILL.md index 2a814b87bc..64aea85d07 100644 --- a/.gemini/skills/docs-writer/SKILL.md +++ b/.gemini/skills/docs-writer/SKILL.md @@ -1,7 +1,7 @@ --- name: docs-writer description: - Always use this skill when the task involves writing, reviewing, or editing + Always use this skill when the task involves writing, reviewing, or editing files in the `/docs` directory or any `.md` files in the repository. --- @@ -24,7 +24,7 @@ approach. - **Perspective and tense:** Address the reader as "you." Use active voice and present tense (e.g., "The API returns..."). -- **Tone:** Professional, friendly, and direct. +- **Tone:** Professional, friendly, and direct. - **Clarity:** Use simple vocabulary. Avoid jargon, slang, and marketing hype. - **Global Audience:** Write in standard US English. Avoid idioms and cultural references. @@ -47,8 +47,8 @@ Write precisely to ensure your instructions are unambiguous. "foo" or "bar." - **Quota and limit terminology:** For any content involving resource capacity or using the word "quota" or "limit", strictly adhere to the guidelines in - the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the - administrative bucket and "limit" for the numerical ceiling. + the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for + the administrative bucket and "limit" for the numerical ceiling. ### Formatting and syntax Apply consistent formatting to make documentation visually organized and @@ -120,7 +120,7 @@ accessible. > This is an experimental feature currently under active development. - **Headings:** Use hierarchical headings to support the user journey. -- **Procedures:** +- **Procedures:** - Introduce lists of steps with a complete sentence. - Start each step with an imperative verb. - Number sequential steps; use bullets for non-sequential lists. @@ -134,7 +134,7 @@ accessible. ## Phase 2: Preparation Before modifying any documentation, thoroughly investigate the request and the -surrounding context. +surrounding context. 1. **Clarify:** Understand the core request. Differentiate between writing new content and editing existing content. If the request is ambiguous (e.g., @@ -145,6 +145,8 @@ surrounding context. 4. **Connect:** Identify all referencing pages if changing behavior. Check if `docs/sidebar.json` needs updates. 5. **Plan:** Create a step-by-step plan before making changes. +6. **Audit Docset:** If asked to audit the documentation, follow the procedural + guide in [docs-auditing.md](./references/docs-auditing.md). ## Phase 3: Execution Implement your plan by either updating existing files or creating new ones @@ -157,7 +159,7 @@ documentation. - **Gaps:** Identify areas where the documentation is incomplete or no longer reflects existing code. -- **Structure:** Apply "Structure (New Docs)" rules (BLUF, headings, etc.) when +- **Structure:** Apply "Structure (New Docs)" rules (BLUF, headings, etc.) when adding new sections to existing pages. - **Headers**: If you change a header, you must check for links that lead to that header and update them. @@ -168,15 +170,16 @@ documentation. documents. ## Phase 4: Verification and finalization -Perform a final quality check to ensure that all changes are correctly formatted -and that all links are functional. +Perform a final quality check to ensure that all changes are correctly +formatted and that all links are functional. 1. **Accuracy:** Ensure content accurately reflects the implementation and technical behavior. 2. **Self-review:** Re-read changes for formatting, correctness, and flow. -3. **Link check:** Verify all new and existing links leading to or from modified - pages. If you changed a header, ensure that any links that lead to it are - updated. -4. **Format:** Once all changes are complete, ask to execute `npm run format` - to ensure consistent formatting across the project. If the user confirms, - execute the command. +3. **Link check:** Verify all new and existing links leading to or from + modified pages. If you changed a header, ensure that any links that lead to + it are updated. +4. **Format:** If `npm run format` fails, it may be necessary to run `npm + install` first to ensure all formatting dependencies are available. Once all + changes are complete, ask to execute `npm run format` to ensure consistent + formatting across the project. If the user confirms, execute the command. diff --git a/.gemini/skills/docs-writer/references/docs-auditing.md b/.gemini/skills/docs-writer/references/docs-auditing.md new file mode 100644 index 0000000000..bf4a2f47ec --- /dev/null +++ b/.gemini/skills/docs-writer/references/docs-auditing.md @@ -0,0 +1,195 @@ +# Procedural Guide: Auditing the Docset + +This guide outlines the process for auditing the Gemini CLI documentation for +correctness and adherence to style guidelines. This process involves both an +"Editor" and "Technical Writer" phase. + +## Objective + +To ensure all public-facing documentation is accurate, up-to-date, adheres to +the Gemini CLI documentation style guide, and reflects the current state of the +codebase. + +## Phase 1: Editor Audit + +**Role:** The editor is responsible for identifying potential issues based on +style guide violations and technical inaccuracies. + +### Steps + +1. **Identify Documentation Scope:** + - Read `docs/sidebar.json` to get a list of all viewable documentation + pages. + - For each entry with a `slug`, convert it into a file path (e.g., `docs` -> + `docs/index.md`, `docs/get-started` -> `docs/get-started.md`). Ignore + entries with `link` properties. + +2. **Prepare Audit Results File:** + - Create a new Markdown file named `audit-results-[YYYY-MM-DD].md` (e.g., + `audit-results-2026-03-13.md`). This file will contain all identified + violations and recommendations. + +3. **Retrieve Style Guidelines:** + - Familiarize yourself with the `docs-writer` skill instructions and the + included style guidelines. + +4. **Audit Each Document:** + - For each documentation file identified in Step 1, read its content. + - **Review against Style Guide:** + - **Voice and Tone Violations:** + - **Unprofessional Tone:** Identify phrasing that is overly casual, + defensive, or lacks a professional and friendly demeanor. + - **Indirectness or Vagueness:** Identify sentences that are + unnecessarily wordy or fail to be concise and direct. + - **Incorrect Pronoun:** Identify any use of third-person pronouns + (e.g., "we," "they," "the user") when referring to the reader, instead + of the second-person pronoun **"you"**. + - **Passive Voice:** Identify sentences written in the passive voice. + - **Incorrect Tense:** Identify the use of past or future tense verbs, + instead of the **present tense**. + - **Poor Vocabulary:** Identify the use of jargon, slang, or overly + informal language. + - **Language and Grammar Violations:** + - **Lack of Conciseness:** Identify unnecessarily long phrases or + sentences. + - **Punctuation Errors:** Identify incorrect or missing punctuation. + - **Ambiguous Dates:** Identify dates that could be misinterpreted + (e.g., "next Monday" instead of "April 15, 2026"). + - **Abbreviation Usage:** Identify the use of abbreviations that should + be spelled out (e.g., "e.g." instead of "for example"). + - **Terminology:** Check for incorrect or inconsistent use of + product-specific terms (e.g., "quota" vs. "limit"). + - **Formatting and Syntax Violations:** + - **Missing Overview:** Check for the absence of a brief overview + paragraph at the start of the document. + - **Line Length:** Identify any lines of text that exceed **80 + characters** (text wrap violation). + - **Casing:** Identify incorrect casing for headings, titles, or named + entities (e.g., product names like `Gemini CLI`). + - **List Formatting:** Identify incorrectly formatted lists (e.g., + inconsistent indentation or numbering). + - **Incorrect Emphasis:** Identify incorrect use of bold text (should + only be used for UI elements) or code font (should be used for code, + file names, or command-line input). + - **Link Quality:** Identify links with non-descriptive anchor text + (e.g., "click here"). + - **Image Alt Text:** Identify images with missing or poor-quality + (non-descriptive) alt text. + - **Structure Violations:** + - **Missing BLUF:** Check for the absence of a "Bottom Line Up Front" + summary at the start of complex sections or documents. + - **Experimental Feature Notes:** Identify experimental features that + are not clearly labeled with a standard note. + - **Heading Hierarchy:** Check for skipped heading levels (e.g., going + from `##` to `####`). + - **Procedure Clarity:** Check for procedural steps that do not start + with an imperative verb or where a condition is placed _after_ the + instruction. + - **Element Misuse:** Identify the incorrect or inappropriate use of + special elements (e.g., Notes, Warnings, Cautions). + - **Table of Contents:** Identify the presence of a dynamically + generated or manually included table of contents. + - **Missing Next Steps:** Check for procedural documents that lack a + "Next steps" section (if applicable). + - **Verify Code Accuracy (if applicable):** + - If the document contains code snippets (e.g., shell commands, API calls, + file paths, Docker image versions), use `grep_search` and `read_file` + within the `packages/` directory (or other relevant parts of the + codebase) to ensure the code is still accurate and up-to-date. Pay close + attention to version numbers, package names, and command syntax. + - **Record Findings:** For each **violation** or inaccuracy found: + - Note the file path. + - Describe the violation (e.g., "Violation (Language and Grammar): Uses + 'e.g.'"). + - Provide a clear and actionable recommendation to correct the issue. + (e.g., "Recommendation: Replace 'e.g.' with 'for example'." or + "Recommendation: Replace '...' with '...' in active voice.). + - Append these findings to `audit-results-[YYYY-MM-DD].md`. + +## Phase 2: Software Engineer Audit + +**Role:** The software engineer is responsible for finding undocumented features +by auditing the codebase and recent changelogs, and passing these findings to +the technical writer. + +### Steps + +1. **Proactive Codebase Audit:** + - Audit high-signal areas of the codebase to identify undocumented features. + You MUST review: + - `packages/cli/src/commands/` + - `packages/core/src/tools/` + - `packages/cli/src/config/settings.ts` + +2. **Review Recent Updates:** + - Check recent changelogs in stable and announcements within the + documentation to see if newly introduced features are documented properly. + +3. **Evaluate and Record Findings:** + - Determine if these features are adequately covered in the docs. They do + not need to be documented word for word, but major features that customers + should care about probably should have an article. + - Append your findings to the `audit-results-[YYYY-MM-DD].md` file, + providing a brief description of the feature and where it should be + documented. + +## Phase 3: Technical Writer Implementation + +**Role:** The technical writer handles input from both the editor and the +software engineer, makes appropriate decisions about what to change, and +implements the approved changes. + +### Steps + +1. **Review Audit Results:** + - Read `audit-results-[YYYY-MM-DD].md` to understand all identified issues, + undocumented features, and recommendations from both the Editor and + Software Engineer phases. + +2. **Make Decisions and Log Reasoning:** + - Create or update an implementation log (e.g., + `audit-implementation-log-[YYYY-MM-DD].md`). + - Make sure the logs are updated for all steps, documenting your reasoning + for each recommendation (why it was accepted, modified, or rejected). This + is required for a final check by a human in the PR. + +3. **Implement Changes:** + - For each approved recommendation: + - Read the target documentation file. + - Apply the recommended change using the `replace` tool. Pay close + attention to `old_string` for exact matches, including whitespace and + newlines. For multiple occurrences of the same simple string (e.g., + "e.g."), use `allow_multiple: true`. + - **String replacement safeguards:** When applying these fixes across the + docset, you must verify the following: + - **Preserve Code Blocks:** Explicitly verify that no code blocks, + inline code snippets, terminal commands, or file paths have been + erroneously capitalized or modified. + - **Preserve Literal Strings:** Never alter the wording of literal error + messages, UI quotes, or system logs. For example, if a style rule says + to remove the word "please", you must NOT remove it if it appears + inside a quoted error message (e.g., + `Error: Please contact your administrator`). + - **Verify Sentence Casing:** When removing filler words (like "please") + from the beginning of a sentence or list item, always verify that the + new first word of the sentence is properly capitalized. + - For structural changes (e.g., adding an overview paragraph), use + `replace` or `write_file` as appropriate. + - For broken links, determine the correct new path or update the link + text. + - For creating new files (e.g., `docs/get-started.md` to fix a broken + link, or a new feature article), use `write_file`. + +4. **Execute Auto-Generation Scripts:** + - Some documentation pages are auto-generated from the codebase and should + be updated using npm scripts rather than manual edits. After implementing + manual changes (especially if you edited settings or configurations based + on SWE recommendations), ensure you run: + - `npm run docs:settings` to generate/update the configuration reference. + - `npm run docs:keybindings` to generate/update the keybindings reference. + +5. **Format Code:** + - **Dependencies:** If `npm run format` fails, it may be necessary to run + `npm install` first to ensure all formatting dependencies are available. + - After all changes have been implemented, run `npm run format` to ensure + consistent formatting across the project. diff --git a/.github/workflows/docs-audit.yml b/.github/workflows/docs-audit.yml new file mode 100644 index 0000000000..4e63077c3b --- /dev/null +++ b/.github/workflows/docs-audit.yml @@ -0,0 +1,50 @@ +name: 'Weekly Docs Audit' + +on: + schedule: + # Runs every Monday at 00:00 UTC + - cron: '0 0 * * MON' + workflow_dispatch: + +jobs: + audit-docs: + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + pull-requests: 'write' + + steps: + - name: 'Checkout repository' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' + with: + fetch-depth: 0 + ref: 'main' + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version: '20' + + - name: 'Run Docs Audit with Gemini' + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' + with: + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + prompt: | + Activate the 'docs-writer' skill. + + **Task:** Execute the docs audit procedure, as defined in your 'docs-auditing.md' reference. + + - name: 'Create Pull Request with Audit Results' + uses: 'peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c' + with: + token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + commit-message: 'docs: weekly audit results for ${{ github.run_id }}' + title: 'Docs Audit for Week of ${{ github.event.schedule }}' + body: | + This PR contains the auto-generated documentation audit for the week. It includes a new `audit-results-*.md` file with findings and any direct fixes applied by the agent. + + Please review the suggestions and merge. + branch: 'docs-audit-${{ github.run_id }}' + base: 'main' + team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers' + delete-branch: true diff --git a/packages/cli/src/ui/hooks/useAgentStream.test.tsx b/packages/cli/src/ui/hooks/useAgentStream.test.tsx new file mode 100644 index 0000000000..53bb512504 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAgentStream.test.tsx @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import type { LegacyAgentProtocol } from '@google/gemini-cli-core'; +import { renderHookWithProviders } from '../../test-utils/render.js'; + +// --- MOCKS --- + +const mockLegacyAgentProtocol = vi.hoisted(() => ({ + send: vi.fn().mockResolvedValue({ streamId: 'test-stream-id' }), + subscribe: vi.fn().mockReturnValue(() => {}), + abort: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../contexts/SessionContext.js', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useSessionStats: vi.fn(() => ({ + startNewPrompt: vi.fn(), + })), + }; +}); + +// --- END MOCKS --- + +import { useAgentStream } from './useAgentStream.js'; +import { MessageType, StreamingState } from '../types.js'; + +describe('useAgentStream', () => { + const mockAddItem = vi.fn(); + const mockOnCancelSubmit = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize on mount', async () => { + await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + expect(mockLegacyAgentProtocol.subscribe).toHaveBeenCalled(); + }); + + it('should call agent.send when submitQuery is called', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + await act(async () => { + await result.current.submitQuery('hello'); + }); + + expect(mockLegacyAgentProtocol.send).toHaveBeenCalledWith({ + message: { content: [{ type: 'text', text: 'hello' }] }, + }); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ type: MessageType.USER, text: 'hello' }), + expect.any(Number), + ); + }); + + it('should update streamingState based on agent_start and agent_end events', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock + .calls[0][0]; + + expect(result.current.streamingState).toBe(StreamingState.Idle); + + act(() => { + eventHandler({ + type: 'agent_start', + id: '1', + timestamp: '', + streamId: '', + }); + }); + expect(result.current.streamingState).toBe(StreamingState.Responding); + + act(() => { + eventHandler({ + type: 'agent_end', + reason: 'completed', + id: '2', + timestamp: '', + streamId: '', + }); + }); + expect(result.current.streamingState).toBe(StreamingState.Idle); + }); + + it('should accumulate text content and update pendingHistoryItems', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock + .calls[0][0]; + + act(() => { + eventHandler({ + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'Hello' }], + id: '1', + timestamp: '', + streamId: '', + }); + }); + + expect(result.current.pendingHistoryItems).toHaveLength(1); + expect(result.current.pendingHistoryItems[0]).toMatchObject({ + type: 'gemini', + text: 'Hello', + }); + + act(() => { + eventHandler({ + type: 'message', + role: 'agent', + content: [{ type: 'text', text: ' world' }], + id: '2', + timestamp: '', + streamId: '', + }); + }); + + expect(result.current.pendingHistoryItems[0].text).toBe('Hello world'); + }); + + it('should process thought events and update thought state', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock + .calls[0][0]; + + act(() => { + eventHandler({ + type: 'message', + role: 'agent', + content: [{ type: 'thought', thought: '**Thinking** about tests' }], + id: '1', + timestamp: '', + streamId: '', + }); + }); + + expect(result.current.thought).toEqual({ + subject: 'Thinking', + description: 'about tests', + }); + }); + + it('should call agent.abort when cancelOngoingRequest is called', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + await act(async () => { + await result.current.cancelOngoingRequest(); + }); + + expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled(); + expect(mockOnCancelSubmit).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts new file mode 100644 index 0000000000..81dbb1e9e9 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -0,0 +1,528 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import { + getErrorMessage, + MessageSenderType, + debugLogger, + geminiPartsToContentParts, + parseThought, + CoreToolCallStatus, + type ApprovalMode, + Kind, + type ThoughtSummary, + type RetryAttemptPayload, + type AgentEvent, + type AgentProtocol, + type Logger, + type Part, +} from '@google/gemini-cli-core'; +import type { + HistoryItemWithoutId, + LoopDetectionConfirmationRequest, + IndividualToolCallDisplay, + HistoryItemToolGroup, +} from '../types.js'; +import { StreamingState, MessageType } from '../types.js'; +import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; +import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; +import { type BackgroundTask } from './useExecutionLifecycle.js'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { useStateAndRef } from './useStateAndRef.js'; +import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js'; + +export interface UseAgentStreamOptions { + agent?: AgentProtocol; + addItem: UseHistoryManagerReturn['addItem']; + onCancelSubmit: (shouldRestorePrompt?: boolean) => void; + isShellFocused?: boolean; + logger?: Logger | null; +} + +/** + * useAgentStream implements the interactive agent loop using an AgentProtocol. + * It is completely agnostic to the specific agent implementation. + */ +export const useAgentStream = ({ + agent, + addItem, + onCancelSubmit, + isShellFocused, + logger, +}: UseAgentStreamOptions) => { + const [initError] = useState(null); + const [retryStatus] = useState(null); + const [streamingState, setStreamingState] = useState( + StreamingState.Idle, + ); + const [thought, setThought] = useState(null); + const [lastOutputTime, setLastOutputTime] = useState(Date.now()); + + const currentStreamIdRef = useRef(null); + const userMessageTimestampRef = useRef(0); + const geminiMessageBufferRef = useRef(''); + const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = + useStateAndRef(null); + + const [trackedTools, , setTrackedTools] = useStateAndRef< + IndividualToolCallDisplay[] + >([]); + const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] = + useStateAndRef>(new Set()); + const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] = + useStateAndRef(true); + + const { startNewPrompt } = useSessionStats(); + + // TODO: Implement dynamic shell-related state derivation from trackedTools or dedicated refs. + // This includes activePtyId, backgroundTasks, and related visibility states to restore + // parity with legacy terminal focus detection and background task tracking. + // Note: Avoid checking ITERM_SESSION_ID for terminal detection and ensure context is sanitized. + const activePtyId = undefined; + const backgroundTaskCount = 0; + const isBackgroundTaskVisible = false; + const toggleBackgroundTasks = useCallback(() => {}, []); + const backgroundCurrentExecution = undefined; + const backgroundTasks = useMemo(() => new Map(), []); + const dismissBackgroundTask = useCallback(async (_pid: number) => {}, []); + + // Use the trackedTools to mock pendingToolCalls for inactivity monitors + const pendingToolCalls = useMemo( + (): MinimalTrackedToolCall[] => + trackedTools.map((t) => ({ + request: { + name: t.originalRequestName || t.name, + args: { command: t.description }, + callId: t.callId, + isClientInitiated: t.isClientInitiated ?? false, + prompt_id: '', + }, + status: t.status, + })), + [trackedTools], + ); + + // TODO: Support LoopDetection confirmation requests + const [loopDetectionConfirmationRequest] = + useState(null); + + const flushPendingText = useCallback(() => { + if (pendingHistoryItemRef.current) { + addItem(pendingHistoryItemRef.current, userMessageTimestampRef.current); + setPendingHistoryItem(null); + geminiMessageBufferRef.current = ''; + } + }, [addItem, pendingHistoryItemRef, setPendingHistoryItem]); + + const cancelOngoingRequest = useCallback(async () => { + if (agent) { + await agent.abort(); + setStreamingState(StreamingState.Idle); + onCancelSubmit(false); + } + }, [agent, onCancelSubmit]); + + // TODO: Support native handleApprovalModeChange for Plan Mode + const handleApprovalModeChange = useCallback( + async (newApprovalMode: ApprovalMode) => { + debugLogger.debug(`Approval mode changed to ${newApprovalMode} (stub)`); + }, + [], + ); + + const handleEvent = useCallback( + (event: AgentEvent) => { + setLastOutputTime(Date.now()); + switch (event.type) { + case 'agent_start': + setStreamingState(StreamingState.Responding); + break; + case 'agent_end': + setStreamingState(StreamingState.Idle); + flushPendingText(); + break; + case 'message': + if (event.role === 'agent') { + for (const part of event.content) { + if (part.type === 'text') { + geminiMessageBufferRef.current += part.text; + // Update pending history item with incremental text + const splitPoint = findLastSafeSplitPoint( + geminiMessageBufferRef.current, + ); + if (splitPoint === geminiMessageBufferRef.current.length) { + setPendingHistoryItem({ + type: 'gemini', + text: geminiMessageBufferRef.current, + }); + } else { + const before = geminiMessageBufferRef.current.substring( + 0, + splitPoint, + ); + const after = + geminiMessageBufferRef.current.substring(splitPoint); + addItem( + { type: 'gemini', text: before }, + userMessageTimestampRef.current, + ); + geminiMessageBufferRef.current = after; + setPendingHistoryItem({ + type: 'gemini_content', + text: after, + }); + } + } else if (part.type === 'thought') { + setThought(parseThought(part.thought)); + } + } + } + break; + case 'tool_request': { + flushPendingText(); + const legacyState = event._meta?.legacyState; + const displayName = legacyState?.displayName ?? event.name; + const isOutputMarkdown = legacyState?.isOutputMarkdown ?? false; + const desc = legacyState?.description ?? ''; + + const fallbackKind = Kind.Other; + + const newCall: IndividualToolCallDisplay = { + callId: event.requestId, + name: displayName, + originalRequestName: event.name, + description: desc, + status: CoreToolCallStatus.Scheduled, + isClientInitiated: false, + renderOutputAsMarkdown: isOutputMarkdown, + kind: legacyState?.kind ?? fallbackKind, + confirmationDetails: undefined, + resultDisplay: undefined, + }; + setTrackedTools((prev) => [...prev, newCall]); + break; + } + case 'tool_update': { + setTrackedTools((prev) => + prev.map((tc): IndividualToolCallDisplay => { + if (tc.callId !== event.requestId) return tc; + + const legacyState = event._meta?.legacyState; + const evtStatus = legacyState?.status; + + let status = tc.status; + if (evtStatus === 'executing') + status = CoreToolCallStatus.Executing; + else if (evtStatus === 'error') status = CoreToolCallStatus.Error; + else if (evtStatus === 'success') + status = CoreToolCallStatus.Success; + + const liveOutput = + event.displayContent?.[0]?.type === 'text' + ? event.displayContent[0].text + : tc.resultDisplay; + const progressMessage = + legacyState?.progressMessage ?? tc.progressMessage; + const progress = legacyState?.progress ?? tc.progress; + const progressTotal = + legacyState?.progressTotal ?? tc.progressTotal; + const ptyId = legacyState?.pid ?? tc.ptyId; + const description = legacyState?.description ?? tc.description; + + return { + ...tc, + status, + resultDisplay: liveOutput, + progressMessage, + progress, + progressTotal, + ptyId, + description, + }; + }), + ); + break; + } + case 'tool_response': { + setTrackedTools((prev) => + prev.map((tc): IndividualToolCallDisplay => { + if (tc.callId !== event.requestId) return tc; + + const legacyState = event._meta?.legacyState; + const outputFile = legacyState?.outputFile; + const resultDisplay = + event.displayContent?.[0]?.type === 'text' + ? event.displayContent[0].text + : tc.resultDisplay; + + return { + ...tc, + status: event.isError + ? CoreToolCallStatus.Error + : CoreToolCallStatus.Success, + resultDisplay, + outputFile, + }; + }), + ); + break; + } + + case 'error': + addItem( + { type: MessageType.ERROR, text: event.message }, + userMessageTimestampRef.current, + ); + break; + + case 'initialize': + case 'session_update': + case 'elicitation_request': + case 'elicitation_response': + case 'usage': + case 'custom': + // These events are currently not handled in the UI + break; + + default: + debugLogger.error('Unknown agent event type:', event); + event satisfies never; + break; + } + }, + [ + addItem, + flushPendingText, + setPendingHistoryItem, + setTrackedTools, + setStreamingState, + setThought, + setLastOutputTime, + ], + ); + + useEffect(() => { + const unsubscribe = agent?.subscribe(handleEvent); + return () => unsubscribe?.(); + }, [agent, handleEvent]); + + const submitQuery = useCallback( + async ( + query: Part[] | string, + options?: { isContinuation: boolean }, + _prompt_id?: string, + ) => { + if (!agent) return; + + const timestamp = Date.now(); + setLastOutputTime(timestamp); + userMessageTimestampRef.current = timestamp; + + geminiMessageBufferRef.current = ''; + + if (!options?.isContinuation) { + if (typeof query === 'string') { + addItem({ type: MessageType.USER, text: query }, timestamp); + void logger?.logMessage(MessageSenderType.USER, query); + } + startNewPrompt(); + } + + const parts = geminiPartsToContentParts( + typeof query === 'string' ? [{ text: query }] : query, + ); + + try { + const { streamId } = await agent.send({ + message: { content: parts }, + }); + currentStreamIdRef.current = streamId; + } catch (err) { + addItem( + { type: MessageType.ERROR, text: getErrorMessage(err) }, + timestamp, + ); + } + }, + [agent, addItem, logger, startNewPrompt], + ); + + useEffect(() => { + if (trackedTools.length > 0) { + const isNewBatch = !trackedTools.some((tc) => + pushedToolCallIdsRef.current.has(tc.callId), + ); + if (isNewBatch) { + setPushedToolCallIds(new Set()); + setIsFirstToolInGroup(true); + } + } else if (streamingState === StreamingState.Idle) { + setPushedToolCallIds(new Set()); + setIsFirstToolInGroup(true); + } + }, [ + trackedTools, + pushedToolCallIdsRef, + setPushedToolCallIds, + setIsFirstToolInGroup, + streamingState, + ]); + + // Push completed tools to history + useEffect(() => { + const toolsToPush: IndividualToolCallDisplay[] = []; + for (let i = 0; i < trackedTools.length; i++) { + const tc = trackedTools[i]; + if (pushedToolCallIdsRef.current.has(tc.callId)) continue; + + if ( + tc.status === 'success' || + tc.status === 'error' || + tc.status === 'cancelled' + ) { + toolsToPush.push(tc); + } else { + break; + } + } + + if (toolsToPush.length > 0) { + const newPushed = new Set(pushedToolCallIdsRef.current); + for (const tc of toolsToPush) { + newPushed.add(tc.callId); + } + + const isLastInBatch = + toolsToPush[toolsToPush.length - 1] === + trackedTools[trackedTools.length - 1]; + + const appearance = getToolGroupBorderAppearance( + { type: 'tool_group', tools: trackedTools }, + activePtyId, + !!isShellFocused, + [], + backgroundTasks, + ); + + const historyItem: HistoryItemToolGroup = { + type: 'tool_group', + tools: toolsToPush, + borderTop: isFirstToolInGroupRef.current, + borderBottom: isLastInBatch, + ...appearance, + }; + + addItem(historyItem); + setPushedToolCallIds(newPushed); + setIsFirstToolInGroup(false); + } + }, [ + trackedTools, + pushedToolCallIdsRef, + isFirstToolInGroupRef, + setPushedToolCallIds, + setIsFirstToolInGroup, + addItem, + activePtyId, + isShellFocused, + backgroundTasks, + ]); + + const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { + const remainingTools = trackedTools.filter( + (tc) => !pushedToolCallIds.has(tc.callId), + ); + + const items: HistoryItemWithoutId[] = []; + + const appearance = getToolGroupBorderAppearance( + { type: 'tool_group', tools: trackedTools }, + activePtyId, + !!isShellFocused, + [], + backgroundTasks, + ); + + if (remainingTools.length > 0) { + items.push({ + type: 'tool_group', + tools: remainingTools, + borderTop: pushedToolCallIds.size === 0, + borderBottom: false, + ...appearance, + }); + } + + const allTerminal = + trackedTools.length > 0 && + trackedTools.every( + (tc) => + tc.status === 'success' || + tc.status === 'error' || + tc.status === 'cancelled', + ); + + const allPushed = + trackedTools.length > 0 && + trackedTools.every((tc) => pushedToolCallIds.has(tc.callId)); + + const anyVisibleInHistory = pushedToolCallIds.size > 0; + const anyVisibleInPending = remainingTools.length > 0; + + if ( + trackedTools.length > 0 && + !(allTerminal && allPushed) && + (anyVisibleInHistory || anyVisibleInPending) + ) { + items.push({ + type: 'tool_group' as const, + tools: [], + borderTop: false, + borderBottom: true, + ...appearance, + }); + } + + return items; + }, [ + trackedTools, + pushedToolCallIds, + activePtyId, + isShellFocused, + backgroundTasks, + ]); + + const pendingHistoryItems = useMemo( + () => + [pendingHistoryItem, ...pendingToolGroupItems].filter( + (i): i is HistoryItemWithoutId => i !== undefined && i !== null, + ), + [pendingHistoryItem, pendingToolGroupItems], + ); + + return { + streamingState, + submitQuery, + initError, + pendingHistoryItems, + thought, + cancelOngoingRequest, + pendingToolCalls, + handleApprovalModeChange, + activePtyId, + loopDetectionConfirmationRequest, + lastOutputTime, + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + backgroundTasks, + retryStatus, + dismissBackgroundTask, + }; +}; diff --git a/packages/cli/src/ui/hooks/useShellInactivityStatus.ts b/packages/cli/src/ui/hooks/useShellInactivityStatus.ts index 092e58baae..a1a9175904 100644 --- a/packages/cli/src/ui/hooks/useShellInactivityStatus.ts +++ b/packages/cli/src/ui/hooks/useShellInactivityStatus.ts @@ -5,20 +5,22 @@ */ import { useInactivityTimer } from './useInactivityTimer.js'; -import { useTurnActivityMonitor } from './useTurnActivityMonitor.js'; +import { + useTurnActivityMonitor, + type MinimalTrackedToolCall, +} from './useTurnActivityMonitor.js'; import { SHELL_FOCUS_HINT_DELAY_MS, SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, SHELL_SILENT_WORKING_TITLE_DELAY_MS, } from '../constants.js'; import type { StreamingState } from '../types.js'; -import { type TrackedToolCall } from './useToolScheduler.js'; interface ShellInactivityStatusProps { activePtyId: number | string | null | undefined; lastOutputTime: number; streamingState: StreamingState; - pendingToolCalls: TrackedToolCall[]; + pendingToolCalls: MinimalTrackedToolCall[]; embeddedShellFocused: boolean; isInteractiveShellEnabled: boolean; } diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts index 3b457c4479..c379529ba5 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.ts @@ -79,6 +79,7 @@ export function useToolScheduler( React.Dispatch>, CancelAllFn, number, + Scheduler, ] { // State stores tool calls organized by their originating schedulerId const [toolCallsMap, setToolCallsMap] = useState< @@ -319,6 +320,7 @@ export function useToolScheduler( setToolCallsForDisplay, cancelAll, lastToolOutputTime, + scheduler, ]; } diff --git a/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts b/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts index 8cd7883007..b7297889f3 100644 --- a/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts +++ b/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts @@ -6,8 +6,16 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { StreamingState } from '../types.js'; -import { hasRedirection } from '@google/gemini-cli-core'; -import { type TrackedToolCall } from './useToolScheduler.js'; +import { + hasRedirection, + type CoreToolCallStatus, + type ToolCallRequestInfo, +} from '@google/gemini-cli-core'; + +export interface MinimalTrackedToolCall { + status: CoreToolCallStatus; + request: ToolCallRequestInfo; +} export interface TurnActivityStatus { operationStartTime: number; @@ -21,7 +29,7 @@ export interface TurnActivityStatus { export const useTurnActivityMonitor = ( streamingState: StreamingState, activePtyId: number | string | null | undefined, - pendingToolCalls: TrackedToolCall[] = [], + pendingToolCalls: MinimalTrackedToolCall[] = [], ): TurnActivityStatus => { const [operationStartTime, setOperationStartTime] = useState(0); diff --git a/packages/cli/src/ui/utils/borderStyles.ts b/packages/cli/src/ui/utils/borderStyles.ts index 7b7dba5fc5..fb9ef11fec 100644 --- a/packages/cli/src/ui/utils/borderStyles.ts +++ b/packages/cli/src/ui/utils/borderStyles.ts @@ -29,7 +29,10 @@ export function getToolGroupBorderAppearance( item: | HistoryItem | HistoryItemWithoutId - | { type: 'tool_group'; tools: TrackedToolCall[] }, + | { + type: 'tool_group'; + tools: Array; + }, activeShellPtyId: number | null | undefined, embeddedShellFocused: boolean | undefined, allPendingItems: HistoryItemWithoutId[] = [], @@ -41,7 +44,7 @@ export function getToolGroupBorderAppearance( // If this item has no tools, it's a closing slice for the current batch. // We need to look at the last pending item to determine the batch's appearance. - const toolsToInspect: Array = + const toolsToInspect = item.tools.length > 0 ? item.tools : allPendingItems diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index 757dbdb952..94763c7d40 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -76,7 +76,6 @@ export class LegacyAgentProtocol implements AgentProtocol { this._config = deps.config; this._client = deps.client ?? deps.config.getGeminiClient(); this._promptId = deps.promptId ?? deps.config.promptId ?? ''; - if (deps.scheduler) { this._scheduler = deps.scheduler; } else {