Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44259a00e2 | |||
| f8541cf7a2 | |||
| 6e0bd68e45 | |||
| d3ef6aca40 |
@@ -19,6 +19,6 @@ runs:
|
||||
run: |-
|
||||
echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc
|
||||
echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc
|
||||
echo ""@google:registry=https://wombat-dressing-room.appspot.com/"" >> ~/.npmrc
|
||||
echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc
|
||||
env:
|
||||
INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}'
|
||||
|
||||
@@ -86,7 +86,7 @@ runs:
|
||||
- name: 'Install dependencies for integration tests'
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: 'npm ci'
|
||||
run: 'npm ci --ignore-scripts'
|
||||
|
||||
- name: '🔬 Run integration tests against NPM release'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
|
||||
@@ -68,6 +68,7 @@ jobs:
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
with:
|
||||
upload_artifacts: 'true'
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
|
||||
@@ -131,6 +131,19 @@ jobs:
|
||||
core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`);
|
||||
return labelNames;
|
||||
|
||||
- name: 'Prepare Issue Data'
|
||||
id: 'prepare_issue_data'
|
||||
env:
|
||||
ISSUE_TITLE: >-
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.title || github.event.issue.title }}
|
||||
ISSUE_BODY: >-
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Title: ${ISSUE_TITLE}" > issue_context.md
|
||||
echo "Body:" >> issue_context.md
|
||||
echo "${ISSUE_BODY}" >> issue_context.md
|
||||
|
||||
- name: 'Run Gemini Issue Analysis'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
id: 'gemini_issue_analysis'
|
||||
@@ -140,6 +153,7 @@ jobs:
|
||||
${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.issue_number || inputs.issue_number) || github.event.issue.number }}
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
with:
|
||||
upload_artifacts: 'true'
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
@@ -157,7 +171,10 @@ jobs:
|
||||
"target": "gcp"
|
||||
},
|
||||
"tools": {
|
||||
"core": []
|
||||
"core": [
|
||||
"run_shell_command(echo)",
|
||||
"read_file"
|
||||
]
|
||||
}
|
||||
}
|
||||
prompt: |-
|
||||
@@ -165,15 +182,8 @@ jobs:
|
||||
|
||||
You are an issue triage assistant. Your role is to analyze a GitHub issue and determine the single most appropriate area/ label based on the definitions provided.
|
||||
|
||||
## Issue Context
|
||||
Title: ${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.title || github.event.issue.title }}
|
||||
Body:
|
||||
--- START OF ISSUE BODY ---
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }}
|
||||
--- END OF ISSUE BODY ---
|
||||
|
||||
## Steps
|
||||
1. Analyze the issue context above.
|
||||
1. Use the read_file tool to read the file "issue_context.md" which contains the issue title and body.
|
||||
2. Review the available labels: ${{ env.AVAILABLE_LABELS }}.
|
||||
3. Select exactly one area/ label that best matches the issue based on Reference 1: Area Definitions.
|
||||
4. Fallback Logic:
|
||||
|
||||
@@ -48,6 +48,8 @@ jobs:
|
||||
contents: 'read'
|
||||
issues: 'read'
|
||||
actions: 'read'
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
steps:
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
|
||||
@@ -176,6 +176,7 @@ jobs:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
GEMINI_EXP: 'gemini_exp.json'
|
||||
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
@@ -300,6 +301,7 @@ jobs:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
GEMINI_EXP: 'gemini_exp.json'
|
||||
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
|
||||
@@ -145,8 +145,8 @@ jobs:
|
||||
skip-branch-cleanup: true
|
||||
force-skip-tests: "${{ github.event_name != 'schedule' && github.event.inputs.force_skip_tests == 'true' }}"
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://registry.npmjs.org/' }}"
|
||||
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org/' }}"
|
||||
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://wombat-dressing-room.appspot.com' }}"
|
||||
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://wombat-dressing-room.appspot.com' }}"
|
||||
npm-registry-scope: "${{ vars.NPM_REGISTRY_SCOPE || '@google' }}"
|
||||
cli-package-name: "${{ vars.CLI_PACKAGE_NAME || '@google/gemini-cli' }}"
|
||||
core-package-name: "${{ vars.CORE_PACKAGE_NAME || '@google/gemini-cli-core' }}"
|
||||
|
||||
@@ -1 +1 @@
|
||||
@google:registry=https://wombat-dressing-room.appspot.com/
|
||||
@google:registry=https://wombat-dressing-room.appspot.com
|
||||
@@ -143,16 +143,6 @@ Integrate Gemini CLI directly into your GitHub workflows with
|
||||
- **Custom Workflows**: Build automated, scheduled and on-demand workflows
|
||||
tailored to your team's needs
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> **Security best practice for public repositories:** Never set
|
||||
> `GEMINI_CLI_TRUST_WORKSPACE=true` or use `--skip-trust` in CI/CD workflows
|
||||
> that process untrusted public inputs (like issue titles/bodies or PR comments).
|
||||
> Doing so can expose dynamically generated runner secrets (such as GCP OIDC
|
||||
> service account credentials) to prompt injection attacks. See the
|
||||
> [Trusted Folders documentation](https://www.geminicli.com/docs/cli/trusted-folders)
|
||||
> for more information.
|
||||
|
||||
## 🔐 Authentication Options
|
||||
|
||||
Choose the authentication method that best fits your needs:
|
||||
|
||||
@@ -507,6 +507,7 @@ on GitHub.
|
||||
headlessly in notebook cells or interactively in the built-in terminal
|
||||
([pic](https://imgur.com/a/G0Tn7vi))
|
||||
- 🎉**Gemini CLI Extensions:**
|
||||
|
||||
- **Conductor:** Planning++, Gemini works with you to build out a detailed
|
||||
plan, pull in extra details as needed, ultimately to give the LLM guardrails
|
||||
with artifacts. Measure twice, implement once!
|
||||
@@ -635,6 +636,7 @@ on GitHub.
|
||||
- **Announcement:**
|
||||
[https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/](https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/)
|
||||
- **🎉 New partner extensions:**
|
||||
|
||||
- **Arize:** Seamlessly instrument AI applications with Arize AX and grant
|
||||
direct access to Arize support:
|
||||
|
||||
@@ -674,6 +676,7 @@ on GitHub.
|
||||

|
||||
|
||||
- **🎉 New partner extensions:**
|
||||
|
||||
- **🤗 Hugging Face extension:** Access the Hugging Face hub.
|
||||
([gif](https://drive.google.com/file/d/1LEzIuSH6_igFXq96_tWev11svBNyPJEB/view?usp=sharing&resourcekey=0-LtPTzR1woh-rxGtfPzjjfg))
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ sends them to the model with every prompt. The CLI loads files in the following
|
||||
order:
|
||||
|
||||
1. **Global context file:**
|
||||
|
||||
- **Location:** `~/.gemini/GEMINI.md` (in your user home directory).
|
||||
- **Scope:** Provides default instructions for all your projects.
|
||||
|
||||
2. **Environment and workspace context files:**
|
||||
|
||||
- **Location:** The CLI searches for `GEMINI.md` files in your configured
|
||||
workspace directories and their parent directories.
|
||||
- **Scope:** Provides context relevant to the projects you are currently
|
||||
|
||||
@@ -64,6 +64,7 @@ Gemini CLI takes action.
|
||||
reach an informal agreement on the approach before proceeding.
|
||||
3. **Review the plan:** Once you've agreed on the strategy, Gemini CLI creates
|
||||
a detailed implementation plan as a Markdown file in your plans directory.
|
||||
|
||||
- **View:** You can open and read this file to understand the proposed
|
||||
changes.
|
||||
- **Edit:** Press `Ctrl+X` to open the plan directly in your configured
|
||||
|
||||
@@ -202,6 +202,7 @@ becoming too large and expensive.
|
||||
exchanges) allowed in a single session. Set to `-1` for unlimited (default).
|
||||
|
||||
**Behavior when limit is reached:**
|
||||
|
||||
- **Interactive mode:** The CLI shows an informational message and stops
|
||||
sending requests to the model. You must manually start a new session.
|
||||
- **Non-interactive mode:** The CLI exits with an error.
|
||||
|
||||
@@ -81,6 +81,7 @@ they appear in the UI.
|
||||
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Max Scrollback Length | `ui.maxScrollbackLength` | Maximum number of lines to keep in the terminal scrollback buffer. | `1000` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` |
|
||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||
|
||||
@@ -27,11 +27,13 @@ via a `.gemini/.env` file. See
|
||||
[Persisting Environment Variables](../get-started/authentication.mdx#persisting-environment-variables).
|
||||
|
||||
- Use the project default path (`.gemini/system.md`):
|
||||
|
||||
- `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1`
|
||||
- The CLI reads `./.gemini/system.md` (relative to your current project
|
||||
directory).
|
||||
|
||||
- Use a custom file path:
|
||||
|
||||
- `GEMINI_SYSTEM_MD=/absolute/path/to/my-system.md`
|
||||
- Relative paths are supported and resolved from the current working
|
||||
directory.
|
||||
|
||||
@@ -64,6 +64,7 @@ and Cloud Logging.
|
||||
You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
|
||||
1. Set your Google Cloud project ID:
|
||||
|
||||
- To send telemetry to a separate project:
|
||||
|
||||
**macOS/Linux**
|
||||
@@ -93,8 +94,10 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
```
|
||||
|
||||
2. Authenticate with Google Cloud using one of these methods:
|
||||
|
||||
- **Method A: Application Default Credentials (ADC)**: Use this method for
|
||||
service accounts or standard `gcloud` authentication.
|
||||
|
||||
- For user accounts:
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
@@ -112,6 +115,7 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
```powershell
|
||||
$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\service-account.json"
|
||||
```
|
||||
|
||||
* **Method B: CLI Auth** (Direct export only): Simplest method for local
|
||||
users. Gemini CLI uses the same OAuth credentials you used for login. To
|
||||
enable this, set `useCliAuth: true` in your `.gemini/settings.json`:
|
||||
@@ -133,6 +137,7 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
> telemetry will be disabled.
|
||||
|
||||
3. Ensure your account or service account has these IAM roles:
|
||||
|
||||
- Cloud Trace Agent
|
||||
- Monitoring Metric Writer
|
||||
- Logs Writer
|
||||
|
||||
@@ -117,16 +117,6 @@ the following methods:
|
||||
These methods will trust the current workspace for the duration of the session
|
||||
without prompting.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> **Never set `GEMINI_CLI_TRUST_WORKSPACE=true` or use `--skip-trust` in CI/CD
|
||||
> workflows that process untrusted public inputs** (such as GitHub issues, pull
|
||||
> requests, or comments). Doing so allows a malicious contributor to commit a
|
||||
> crafted `.gemini/settings.json` file in their pull request, register
|
||||
> arbitrary tools (including shell execution), and exfiltrate dynamically
|
||||
> generated runner secrets (such as GCP service account credentials or AWS keys)
|
||||
> via prompt injection.
|
||||
|
||||
For detailed instructions on managing folder trust within CI/CD workflows,
|
||||
review the
|
||||
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
|
||||
|
||||
@@ -56,6 +56,7 @@ creating a "discovery file."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths,
|
||||
delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for
|
||||
@@ -187,6 +188,7 @@ The plugin **MUST** register an `openDiff` tool on its MCP server.
|
||||
- **Response (`CallToolResult`):** The tool **MUST** immediately return a
|
||||
`CallToolResult` to acknowledge the request and report whether the diff view
|
||||
was successfully opened.
|
||||
|
||||
- On Success: If the diff view was opened successfully, the response **MUST**
|
||||
contain empty content (that is, `content: []`).
|
||||
- On Failure: If an error prevented the diff view from opening, the response
|
||||
|
||||
@@ -27,6 +27,7 @@ AI-generated code changes directly within your editor.
|
||||
|
||||
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
||||
to provide more relevant and accurate responses. This context includes:
|
||||
|
||||
- The **10 most recently accessed files** in your workspace.
|
||||
- Your active cursor position.
|
||||
- Any text you have selected (up to a 16KB limit; longer selections will be
|
||||
@@ -228,6 +229,7 @@ If you are using Gemini CLI within a sandbox, be aware of the following:
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||
|
||||
- **Cause:** Gemini CLI could not find the necessary environment variables
|
||||
(`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect
|
||||
to the IDE. This usually means the IDE companion extension is not running or
|
||||
@@ -270,6 +272,7 @@ to connect using the provided PID.
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||
|
||||
- **Cause:** The CLI's current working directory is outside the workspace you
|
||||
have open in your IDE.
|
||||
- **Solution:** `cd` into the same directory that is open in your IDE and
|
||||
@@ -284,6 +287,7 @@ to connect using the provided PID.
|
||||
|
||||
- **Message:**
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||
|
||||
- **Cause:** You are running Gemini CLI in a terminal or environment that is
|
||||
not a supported IDE.
|
||||
- **Solution:** Run Gemini CLI from the integrated terminal of a supported
|
||||
|
||||
@@ -59,6 +59,7 @@ You can view traces in the Jaeger UI for local development.
|
||||
|
||||
This command configures your workspace for local telemetry and provides a
|
||||
link to the Jaeger UI (usually `http://localhost:16686`).
|
||||
|
||||
- **Collector logs:** `~/.gemini/tmp/<projectHash>/otel/collector.log`
|
||||
|
||||
2. **Run Gemini CLI:**
|
||||
@@ -108,6 +109,7 @@ Trace for custom processing or routing.
|
||||
|
||||
The script outputs links to view traces, metrics, and logs in the Google
|
||||
Cloud Console.
|
||||
|
||||
- **Collector logs:** `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log`
|
||||
|
||||
3. **Run Gemini CLI:**
|
||||
|
||||
@@ -506,6 +506,7 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
|
||||
- **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
|
||||
**Alt+z** (Linux/WSL) to undo the last action in the input prompt.
|
||||
|
||||
@@ -519,6 +520,7 @@ At commands are used to include the content of files or directories as part of
|
||||
your prompt to Gemini. These commands include git-aware filtering.
|
||||
|
||||
- **`@<path_to_file_or_directory>`**
|
||||
|
||||
- **Description:** Inject the content of the specified file or files into your
|
||||
current prompt. This is useful for asking questions about specific code,
|
||||
text, or collections of files.
|
||||
@@ -565,6 +567,7 @@ The `!` prefix lets you interact with your system's shell directly from within
|
||||
Gemini CLI.
|
||||
|
||||
- **`!<shell_command>`**
|
||||
|
||||
- **Description:** Execute the given `<shell_command>` using `bash` on
|
||||
Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you
|
||||
override `ComSpec`). Any output or errors from the command are displayed in
|
||||
@@ -574,6 +577,7 @@ Gemini CLI.
|
||||
- `!git status` (executes `git status` and returns to Gemini CLI)
|
||||
|
||||
- **`!` (Toggle shell mode)**
|
||||
|
||||
- **Description:** Typing `!` on its own toggles shell mode.
|
||||
- **Entering shell mode:**
|
||||
- When active, shell mode uses a different coloring and a "Shell Mode
|
||||
|
||||
@@ -70,6 +70,7 @@ Before promoting a `preview` release to `stable`, a release manager must
|
||||
manually run through this checklist.
|
||||
|
||||
- **Setup:**
|
||||
|
||||
- [ ] Uninstall any existing global version:
|
||||
`npm uninstall -g @google/gemini-cli`
|
||||
- [ ] Clear npx cache (optional but recommended): `npm cache clean --force`
|
||||
@@ -77,24 +78,29 @@ manually run through this checklist.
|
||||
- [ ] Verify version: `gemini --version`
|
||||
|
||||
- **Authentication:**
|
||||
|
||||
- [ ] In interactive mode run `/auth` and verify all sign in flows work:
|
||||
- [ ] Sign in with Google
|
||||
- [ ] API Key
|
||||
- [ ] Vertex AI
|
||||
|
||||
- **Basic prompting:**
|
||||
|
||||
- [ ] Run `gemini "Tell me a joke"` and verify a sensible response.
|
||||
- [ ] Run in interactive mode: `gemini`. Ask a follow-up question to test
|
||||
context.
|
||||
|
||||
- **Piped input:**
|
||||
|
||||
- [ ] Run `echo "Summarize this" | gemini` and verify it processes stdin.
|
||||
|
||||
- **Context management:**
|
||||
|
||||
- [ ] In interactive mode, use `@file` to add a local file to context. Ask a
|
||||
question about it.
|
||||
|
||||
- **Settings:**
|
||||
|
||||
- [ ] In interactive mode run `/settings` and make modifications
|
||||
- [ ] Validate that setting is changed
|
||||
|
||||
|
||||
@@ -475,6 +475,7 @@ This stage happens _after_ the NPM publish and creates the single-file
|
||||
executable that enables `npx` usage directly from the GitHub repository.
|
||||
|
||||
1. **The JavaScript bundle is created:**
|
||||
|
||||
- **What happens:** The built JavaScript from both `packages/core/dist` and
|
||||
`packages/cli/dist`, along with all third-party JavaScript dependencies,
|
||||
are bundled by `esbuild` into a single, executable JavaScript file (for
|
||||
@@ -486,6 +487,7 @@ executable that enables `npx` usage directly from the GitHub repository.
|
||||
the `core` package) are included directly.
|
||||
|
||||
2. **The `bundle` directory is assembled:**
|
||||
|
||||
- **What happens:** A temporary `bundle` folder is created at the project
|
||||
root. The single `gemini.js` executable is placed inside it, along with
|
||||
other essential files.
|
||||
|
||||
@@ -127,6 +127,7 @@ Standard/Plus and AI Expanded, are not supported._
|
||||
license seats. For predictable costs, you can sign in with Google.
|
||||
|
||||
This includes the following request limits:
|
||||
|
||||
- Gemini Code Assist Standard edition:
|
||||
- 1500 maximum model requests / user / day
|
||||
- Gemini Code Assist Enterprise edition:
|
||||
|
||||
@@ -12,6 +12,7 @@ topics on:
|
||||
|
||||
- **Error:
|
||||
`You must be a named user on your organization's Gemini Code Assist Standard edition subscription to use this service. Please contact your administrator to request an entitlement to Gemini Code Assist Standard edition.`**
|
||||
|
||||
- **Cause:** This error might occur if Gemini CLI detects the
|
||||
`GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` environment variable is
|
||||
defined. Setting these variables forces an organization subscription check.
|
||||
@@ -19,6 +20,7 @@ topics on:
|
||||
linked to an organizational subscription.
|
||||
|
||||
- **Solution:**
|
||||
|
||||
- **Individual Users:** Unset the `GOOGLE_CLOUD_PROJECT` and
|
||||
`GOOGLE_CLOUD_PROJECT_ID` environment variables. Check and remove these
|
||||
variables from your shell configuration files (for example, `.bashrc`,
|
||||
@@ -30,12 +32,14 @@ topics on:
|
||||
|
||||
- **Error:
|
||||
`Failed to sign in. Message: Your current account is not eligible... because it is not currently available in your location.`**
|
||||
|
||||
- **Cause:** Gemini CLI does not currently support your location. For a full
|
||||
list of supported locations, see the following pages:
|
||||
- Gemini Code Assist for individuals:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
|
||||
- **Error: `Failed to sign in. Message: Request contains an invalid argument`**
|
||||
|
||||
- **Cause:** Users with Google Workspace accounts or Google Cloud accounts
|
||||
associated with their Gmail accounts may not be able to activate the free
|
||||
tier of the Google Code Assist plan.
|
||||
@@ -66,6 +70,7 @@ topics on:
|
||||
## Common error messages and solutions
|
||||
|
||||
- **Error: `EADDRINUSE` (Address already in use) when starting an MCP server.**
|
||||
|
||||
- **Cause:** Another process is already using the port that the MCP server is
|
||||
trying to bind to.
|
||||
- **Solution:** Either stop the other process that is using the port or
|
||||
@@ -73,6 +78,7 @@ topics on:
|
||||
|
||||
- **Error: Command not found (when attempting to run Gemini CLI with
|
||||
`gemini`).**
|
||||
|
||||
- **Cause:** Gemini CLI is not correctly installed or it is not in your
|
||||
system's `PATH`.
|
||||
- **Solution:** The update depends on how you installed Gemini CLI:
|
||||
@@ -85,6 +91,7 @@ topics on:
|
||||
then rebuild using the command `npm run build`.
|
||||
|
||||
- **Error: `MODULE_NOT_FOUND` or import errors.**
|
||||
|
||||
- **Cause:** Dependencies are not installed correctly, or the project hasn't
|
||||
been built.
|
||||
- **Solution:**
|
||||
@@ -93,6 +100,7 @@ topics on:
|
||||
3. Verify that the build completed successfully with `npm run start`.
|
||||
|
||||
- **Error: "Operation not permitted", "Permission denied", or similar.**
|
||||
|
||||
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations
|
||||
that are restricted by your sandbox configuration, such as writing outside
|
||||
the project directory or system temp directory.
|
||||
@@ -101,6 +109,7 @@ topics on:
|
||||
configuration.
|
||||
|
||||
- **Gemini CLI is not running in interactive mode in "CI" environments**
|
||||
|
||||
- **Issue:** Gemini CLI does not enter interactive mode (no prompt appears) if
|
||||
an environment variable starting with `CI_` (for example, `CI_TOKEN`) is
|
||||
set. This is because the `is-in-ci` package, used by the underlying UI
|
||||
@@ -116,6 +125,7 @@ topics on:
|
||||
`env -u CI_TOKEN gemini`
|
||||
|
||||
- **DEBUG mode not working from project .env file**
|
||||
|
||||
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable
|
||||
debug mode for gemini-cli.
|
||||
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded
|
||||
@@ -155,12 +165,14 @@ is especially useful for scripting and automation.
|
||||
## Debugging tips
|
||||
|
||||
- **CLI debugging:**
|
||||
|
||||
- Use the `--debug` flag for more detailed output. In interactive mode, press
|
||||
F12 to view the debug console.
|
||||
- Check the CLI logs, often found in a user-specific configuration or cache
|
||||
directory.
|
||||
|
||||
- **Core debugging:**
|
||||
|
||||
- Check the server console output for error messages or stack traces.
|
||||
- Increase log verbosity if configurable. For example, set the `DEBUG_MODE`
|
||||
environment variable to `true` or `1`.
|
||||
@@ -168,6 +180,7 @@ is especially useful for scripting and automation.
|
||||
step through server-side code.
|
||||
|
||||
- **Tool issues:**
|
||||
|
||||
- If a specific tool is failing, try to isolate the issue by running the
|
||||
simplest possible version of the command or operation the tool performs.
|
||||
- For `run_shell_command`, check that the command works directly in your shell
|
||||
|
||||
@@ -11,6 +11,7 @@ confirmation.
|
||||
- **Display name:** Ask User
|
||||
- **File:** `ask-user.ts`
|
||||
- **Parameters:**
|
||||
|
||||
- `questions` (array of objects, required): A list of 1 to 4 questions to ask.
|
||||
Each question object has the following properties:
|
||||
- `question` (string, required): The complete question text.
|
||||
@@ -30,6 +31,7 @@ confirmation.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
|
||||
- Presents an interactive dialog to the user with the specified questions.
|
||||
- Pauses execution until the user provides answers or dismisses the dialog.
|
||||
- Returns the user's answers to the model.
|
||||
|
||||
@@ -768,6 +768,7 @@ defaults:
|
||||
|
||||
- **Tool lists:** Tool lists are merged securely to ensure the most restrictive
|
||||
policy wins:
|
||||
|
||||
- **Exclusions (`excludeTools`):** Arrays are combined (unioned). If either
|
||||
source blocks a tool, it remains disabled.
|
||||
- **Inclusions (`includeTools`):** Arrays are intersected. If both sources
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"latest-version": "9.0.0",
|
||||
"node-fetch-native": "1.6.7",
|
||||
"proper-lockfile": "4.1.2",
|
||||
@@ -449,8 +449,7 @@
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
|
||||
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"peer": true
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
@@ -1517,7 +1516,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -1568,6 +1566,7 @@
|
||||
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
@@ -1654,6 +1653,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
}
|
||||
@@ -2084,6 +2084,7 @@
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -2125,6 +2126,7 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -2141,7 +2143,8 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@mswjs/interceptors": {
|
||||
"version": "0.39.5",
|
||||
@@ -2240,7 +2243,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2421,7 +2423,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2471,7 +2472,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
|
||||
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2822,7 +2822,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
|
||||
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2857,7 +2856,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz",
|
||||
"integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/resources": "2.7.1"
|
||||
@@ -2913,7 +2911,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz",
|
||||
"integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
@@ -4140,7 +4137,6 @@
|
||||
"integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4256,6 +4252,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -5117,7 +5114,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7818,7 +7814,6 @@
|
||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8426,7 +8421,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -8471,6 +8465,7 @@
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
@@ -8610,7 +8605,8 @@
|
||||
"integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-string-width": {
|
||||
"version": "3.0.2",
|
||||
@@ -8619,6 +8615,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-string-truncated-width": "^3.0.2"
|
||||
}
|
||||
@@ -8646,6 +8643,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-string-width": "^3.0.2"
|
||||
}
|
||||
@@ -9998,11 +9996,10 @@
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
|
||||
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.1.0.tgz",
|
||||
"integrity": "sha512-OM49V37BUVfbG77zyT3YIDvwKosEOz1fBIBY5FI00DefrJGcfDc4GUVynb9GdjwwJwSNMe39VLH3VdG4bJ718A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
@@ -10902,6 +10899,7 @@
|
||||
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
@@ -10994,7 +10992,8 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
@@ -11996,7 +11995,6 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bundled-es-modules/cookie": "^2.0.1",
|
||||
"@bundled-es-modules/statuses": "^1.0.1",
|
||||
@@ -13660,7 +13658,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13671,7 +13668,6 @@
|
||||
"integrity": "sha512-ldFwzufLletzCikNJVYaxlxMLu7swJ3T2VrGfzXlMsVhZhPDKXA38DEROidaYZVgMAmQnIjymrmqto5pyfrwPA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -14092,7 +14088,8 @@
|
||||
"integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
@@ -14519,7 +14516,8 @@
|
||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
@@ -15555,6 +15553,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -15834,7 +15833,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15886,6 +15884,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.3"
|
||||
},
|
||||
@@ -15899,7 +15898,8 @@
|
||||
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
@@ -15940,6 +15940,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
@@ -16093,8 +16094,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16102,7 +16102,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16268,7 +16267,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16336,7 +16334,6 @@
|
||||
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.30.1",
|
||||
"@typescript-eslint/types": "8.30.1",
|
||||
@@ -16633,6 +16630,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kettanaito"
|
||||
}
|
||||
@@ -16727,7 +16725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17298,7 +17295,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17311,7 +17307,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17813,7 +17808,6 @@
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
@@ -17960,7 +17954,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -18178,7 +18171,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
@@ -18254,7 +18246,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18316,7 +18307,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -18503,7 +18493,7 @@
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"ink-gradient": "3.0.0",
|
||||
"ink-spinner": "5.0.0",
|
||||
"latest-version": "9.0.0",
|
||||
@@ -18830,7 +18820,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18917,7 +18906,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -19590,6 +19578,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -19602,8 +19591,7 @@
|
||||
"version": "0.0.1367902",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
|
||||
"integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"packages/core/node_modules/dotenv": {
|
||||
"version": "17.2.4",
|
||||
@@ -19766,7 +19754,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz",
|
||||
"integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -19934,7 +19921,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -20117,6 +20103,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
@@ -20140,6 +20127,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/ansi": "^2.0.7",
|
||||
"@inquirer/figures": "^2.0.7",
|
||||
@@ -20168,6 +20156,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
}
|
||||
@@ -20179,6 +20168,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
@@ -20198,6 +20188,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-draft/deferred-promise": "^2.2.0",
|
||||
"@open-draft/logger": "^0.3.0",
|
||||
@@ -20244,6 +20235,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"set-cookie-parser": "^3.0.1"
|
||||
@@ -20257,6 +20249,7 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/confirm": "^6.0.11",
|
||||
"@mswjs/interceptors": "^0.41.3",
|
||||
@@ -20301,7 +20294,8 @@
|
||||
"integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"packages/core/node_modules/vitest/node_modules/mute-stream": {
|
||||
"version": "3.0.0",
|
||||
@@ -20310,6 +20304,7 @@
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
@@ -20321,6 +20316,7 @@
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
@@ -20559,7 +20555,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -20583,7 +20578,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -21335,7 +21329,6 @@
|
||||
"integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.31.1",
|
||||
"@typescript-eslint/types": "8.31.1",
|
||||
@@ -21557,7 +21550,6 @@
|
||||
"integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -21642,7 +21634,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
@@ -21786,7 +21777,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
|
||||
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
|
||||
"eval:inventory": "tsx ./scripts/eval-inventory-cli.ts",
|
||||
"eval:inventory:json": "tsx ./scripts/eval-inventory-cli.ts --json",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start --",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
@@ -74,7 +75,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -144,7 +145,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"latest-version": "9.0.0",
|
||||
"node-fetch-native": "1.6.7",
|
||||
"proper-lockfile": "4.1.2",
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
`MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
|
||||
element is available, avoiding potential rendering timing issues.
|
||||
- Avoid prop drilling when at all possible.
|
||||
- **StaticRender**: Unlike Ink's native `<Static>` (which is printed above the
|
||||
application layout and takes no space in the flex container), the custom
|
||||
`<StaticRender>` component preserves its layout and _does_ take up its
|
||||
measured height in the active flex container.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"ink-gradient": "3.0.0",
|
||||
"ink-spinner": "5.0.0",
|
||||
"latest-version": "9.0.0",
|
||||
|
||||
@@ -842,6 +842,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxScrollbackLength: {
|
||||
type: 'number',
|
||||
label: 'Max Scrollback Length',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: 1000,
|
||||
description:
|
||||
'Maximum number of lines to keep in the terminal scrollback buffer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showSpinner: {
|
||||
type: 'boolean',
|
||||
label: 'Show Spinner',
|
||||
|
||||
@@ -167,6 +167,7 @@ export async function startInteractiveUI(
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
debugRainbow: settings.merged.ui.debugRainbow === true,
|
||||
maxScrollbackLength: settings.merged.ui.maxScrollbackLength,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
@@ -13,7 +14,7 @@ import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
|
||||
import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
|
||||
export const App = () => {
|
||||
export const App = React.memo(() => {
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
@@ -35,4 +36,6 @@ export const App = () => {
|
||||
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
App.displayName = 'App';
|
||||
|
||||
@@ -1557,6 +1557,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
|
||||
);
|
||||
|
||||
// In terminalBuffer mode, we return terminalHeight - 1 to prevent frequent
|
||||
// invalidation of UIState. This value is correct for the few cases where a
|
||||
// fixed terminal height must be respected.
|
||||
const uiStateAvailableTerminalHeight = config.getUseTerminalBuffer()
|
||||
? terminalHeight - 1
|
||||
: availableTerminalHeight;
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
|
||||
terminalHeight: Math.max(
|
||||
@@ -2488,7 +2495,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlDPressedOnce: ctrlDPressCount >= 1,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
@@ -2502,7 +2508,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
availableTerminalHeight: uiStateAvailableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
@@ -2601,7 +2607,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlDPressCount,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
@@ -2614,7 +2619,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
allowPlanMode,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
uiStateAvailableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
|
||||
@@ -61,6 +61,28 @@ Tips for getting started:
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
@@ -109,42 +131,42 @@ DialogManager
|
||||
`;
|
||||
|
||||
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
" ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
HistoryItemDisplay
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required │
|
||||
│ │
|
||||
│ ? ls list directory │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ ls │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ Allow execution of [ls]? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Gemini CLI v1.2.3 █
|
||||
█
|
||||
█
|
||||
█
|
||||
Tips for getting started: █
|
||||
1. Create GEMINI.md files to customize your interactions █
|
||||
2. /help for more information █
|
||||
3. Ask coding questions, edit code or run commands █
|
||||
4. Be specific for the best results █
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
HistoryItemDisplay █
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────█
|
||||
│ Action Required █
|
||||
│ █
|
||||
│ ? ls list directory █
|
||||
│ █
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ █
|
||||
│ │ ls │ █
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ █
|
||||
│ Allow execution of [ls]? █
|
||||
│ █
|
||||
│ ● 1. Allow once █
|
||||
│ 2. Allow for this session █
|
||||
│ 3. No, suggest changes (esc) █
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────█
|
||||
Notifications
|
||||
|
||||
Composer
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
</style>
|
||||
<rect width="920" height="666" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<rect x="0" y="0" width="9" height="17" fill="#141414" />
|
||||
<rect x="9" y="0" width="18" height="17" fill="#141414" />
|
||||
<text x="9" y="2" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="27" y="0" width="324" height="17" fill="#141414" />
|
||||
<text x="27" y="2" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
|
||||
<rect x="351" y="0" width="549" height="17" fill="#141414" />
|
||||
<text x="0" y="19" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="70" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">? Edit </text>
|
||||
@@ -73,7 +66,7 @@
|
||||
<text x="216" y="189" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="189" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="189" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│▄</text>
|
||||
<text x="882" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
|
||||
@@ -83,7 +76,7 @@
|
||||
<text x="216" y="206" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="206" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="206" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
|
||||
@@ -93,7 +86,7 @@
|
||||
<text x="216" y="223" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="223" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="223" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
|
||||
@@ -103,7 +96,7 @@
|
||||
<text x="216" y="240" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="240" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="240" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
|
||||
@@ -113,7 +106,7 @@
|
||||
<text x="216" y="257" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="257" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="257" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
|
||||
@@ -123,7 +116,7 @@
|
||||
<text x="216" y="274" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="274" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="274" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
|
||||
@@ -133,7 +126,7 @@
|
||||
<text x="216" y="291" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="291" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="291" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 28 KiB |
@@ -1,8 +1,8 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
|
||||
" > Can you edit InputPrompt.tsx for me?
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
|
||||
@@ -12,13 +12,13 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo
|
||||
│ │ 44 const line44 = true; │ │
|
||||
│ │ 45 const line45 = true; │ │
|
||||
│ │ 46 const line46 = true; │ │
|
||||
│ │ 47 const line47 = true; │ │▄
|
||||
│ │ 48 const line48 = true; │ │█
|
||||
│ │ 49 const line49 = true; │ │█
|
||||
│ │ 50 const line50 = true; │ │█
|
||||
│ │ 51 const line51 = true; │ │█
|
||||
│ │ 52 const line52 = true; │ │█
|
||||
│ │ 53 const line53 = true; │ │█
|
||||
│ │ 47 const line47 = true; │ │
|
||||
│ │ 48 const line48 = true; │ │
|
||||
│ │ 49 const line49 = true; │ │
|
||||
│ │ 50 const line50 = true; │ │
|
||||
│ │ 51 const line51 = true; │ │
|
||||
│ │ 52 const line52 = true; │ │
|
||||
│ │ 53 const line53 = true; │ │
|
||||
│ │ 54 const line54 = true; │ │█
|
||||
│ │ 55 const line55 = true; │ │█
|
||||
│ │ 56 const line56 = true; │ │█
|
||||
|
||||
@@ -439,7 +439,8 @@ describe('extensionsCommand', () => {
|
||||
}
|
||||
|
||||
it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => {
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry = true;
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry =
|
||||
true;
|
||||
|
||||
const result = await exploreAction(mockContext, '');
|
||||
|
||||
@@ -455,7 +456,8 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
|
||||
it('should handle onSelect and onClose in ExtensionRegistryView', async () => {
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry = true;
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry =
|
||||
true;
|
||||
|
||||
const result = await exploreAction(mockContext, '');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
|
||||
@@ -44,6 +44,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
itemKey?: string;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
isPending: boolean;
|
||||
@@ -57,6 +58,7 @@ interface HistoryItemDisplayProps {
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
item,
|
||||
itemKey,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isPending,
|
||||
@@ -102,6 +104,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini' && (
|
||||
<GeminiMessage
|
||||
itemKey={itemKey}
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
@@ -112,6 +115,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_content' && (
|
||||
<GeminiMessageContent
|
||||
itemKey={itemKey}
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
@@ -188,6 +192,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
itemKey={itemKey}
|
||||
item={itemForDisplay}
|
||||
toolCalls={itemForDisplay.tools}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
|
||||
@@ -20,9 +20,9 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
FixedScrollableList,
|
||||
type FixedScrollableListRef,
|
||||
} from './shared/FixedScrollableList.js';
|
||||
import { ListeningIndicator } from './ListeningIndicator.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
@@ -290,7 +290,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
|
||||
const listRef = useRef<FixedScrollableListRef<ScrollableItem>>(null);
|
||||
|
||||
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
|
||||
buffer,
|
||||
@@ -1869,14 +1869,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
{config.getUseTerminalBuffer() ? (
|
||||
<ScrollableList
|
||||
{isAlternateBuffer ? (
|
||||
<FixedScrollableList
|
||||
ref={listRef}
|
||||
hasFocus={focus}
|
||||
data={scrollableData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 1}
|
||||
fixedItemHeight={true}
|
||||
itemHeight={1}
|
||||
keyExtractor={(item) =>
|
||||
item.type === 'visualLine'
|
||||
? `line-${item.absoluteVisualIdx}`
|
||||
@@ -1884,7 +1883,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
width={inputWidth + SCROLLBAR_GUTTER_WIDTH}
|
||||
backgroundColor={listBackgroundColor}
|
||||
containerHeight={Math.min(
|
||||
maxHeight={Math.min(
|
||||
buffer.viewportHeight,
|
||||
scrollableData.length,
|
||||
)}
|
||||
|
||||
@@ -358,6 +358,7 @@ describe('MainContent', () => {
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
terminalWidth: 100,
|
||||
mouseMode: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -803,7 +804,6 @@ describe('MainContent', () => {
|
||||
expect(output).toContain('Planning execution');
|
||||
expect(output).toContain('Refining approach');
|
||||
expect(output).toMatchSnapshot();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { useInputState } from '../contexts/InputContext.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
@@ -37,6 +38,7 @@ export const MainContent = () => {
|
||||
const config = useConfig();
|
||||
const useTerminalBuffer = config.getUseTerminalBuffer();
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const { copyModeEnabled } = useInputState();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showConfirmationQueue = confirmingTool !== null;
|
||||
@@ -114,6 +116,7 @@ export const MainContent = () => {
|
||||
isToolGroupBoundary,
|
||||
}) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
itemKey={item.id.toString()}
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !isExpandable
|
||||
@@ -202,19 +205,27 @@ export const MainContent = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map((data, index) => ({
|
||||
const headerItem = useMemo(() => ({ type: 'header' as const }), []);
|
||||
|
||||
const historyVirtualizedItems = useMemo(
|
||||
() =>
|
||||
augmentedHistory.map((data, index) => ({
|
||||
type: 'history' as const,
|
||||
item: data.item,
|
||||
element: historyItems[index],
|
||||
})),
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[augmentedHistory, historyItems],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
headerItem,
|
||||
...historyVirtualizedItems,
|
||||
{ type: 'pending' as const, pendingHistoryItems },
|
||||
],
|
||||
[headerItem, historyVirtualizedItems, pendingHistoryItems],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
if (item.type === 'header') {
|
||||
@@ -234,7 +245,7 @@ export const MainContent = () => {
|
||||
[showHeaderDetails, version, pendingItems],
|
||||
);
|
||||
|
||||
const estimatedItemHeight = useCallback(() => 100, []);
|
||||
const estimatedItemHeight = useCallback(() => 10, []);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: (typeof virtualizedData)[number], _index: number) => {
|
||||
@@ -249,7 +260,7 @@ export const MainContent = () => {
|
||||
// interactive. Gemini messages and Tool results that are not scrollable,
|
||||
// collapsible, or clickable should also be tagged as static in the future.
|
||||
const isStaticItem = useCallback(
|
||||
(item: (typeof virtualizedData)[number]) => item.type === 'header',
|
||||
(item: (typeof virtualizedData)[number]) => item.type !== 'pending',
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -271,7 +282,7 @@ export const MainContent = () => {
|
||||
renderStatic={useTerminalBuffer}
|
||||
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
|
||||
overflowToBackbuffer={useTerminalBuffer && !isAlternateBuffer}
|
||||
scrollbar={mouseMode}
|
||||
scrollbar={mouseMode && !copyModeEnabled}
|
||||
/>
|
||||
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
|
||||
// as that will reduce the # of cases where we will have to clear the
|
||||
@@ -295,6 +306,7 @@ export const MainContent = () => {
|
||||
isStaticItem,
|
||||
mouseMode,
|
||||
isAlternateBuffer,
|
||||
copyModeEnabled,
|
||||
]);
|
||||
|
||||
if (!uiState.isConfigInitialized) {
|
||||
|
||||
@@ -213,24 +213,3 @@ AppHeader(full)
|
||||
│ refine the solution.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = `
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> Plan a solution
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
Thinking...
|
||||
│
|
||||
│ Initial analysis
|
||||
│ This is a multiple line paragraph for the first thinking message of how the
|
||||
│ model analyzes the problem.
|
||||
│
|
||||
│ Planning execution
|
||||
│ This a second multiple line paragraph for the second thinking message
|
||||
│ explaining the plan in detail so that it wraps around the terminal display.
|
||||
│
|
||||
│ Refining approach
|
||||
│ And finally a third multiple line paragraph for the third thinking message to
|
||||
│ refine the solution."
|
||||
`;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '../../types.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
|
||||
describe('DenseToolMessage', () => {
|
||||
const defaultProps = {
|
||||
@@ -563,6 +564,114 @@ describe('DenseToolMessage', () => {
|
||||
// Verify it shows the diff when expanded
|
||||
expect(lastFrame()).toContain('new line');
|
||||
});
|
||||
|
||||
it('shows diff content when globally expanded inside a VirtualizedList context', async () => {
|
||||
const mockListContext = {
|
||||
registerInteractivity: vi.fn(),
|
||||
setItemState: vi.fn(),
|
||||
getItemState: vi.fn(),
|
||||
isItemToggled: vi.fn().mockReturnValue(false),
|
||||
toggleItem: vi.fn(),
|
||||
registerClickCallback: vi.fn(),
|
||||
unregisterClickCallback: vi.fn(),
|
||||
registerClickableArea: vi.fn(),
|
||||
unregisterClickableArea: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<VirtualizedListContext.Provider
|
||||
value={
|
||||
mockListContext as unknown as React.ContextType<
|
||||
typeof VirtualizedListContext
|
||||
>
|
||||
}
|
||||
>
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
itemKey="item-1"
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>
|
||||
</VirtualizedListContext.Provider>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
toolActions: {
|
||||
isExpanded: () => true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('new line');
|
||||
});
|
||||
|
||||
it('toggles expansion when header is clicked', async () => {
|
||||
const toggleExpansion = vi.fn();
|
||||
const toggleItem = vi.fn();
|
||||
let registeredCallback: (() => void) | undefined;
|
||||
|
||||
const MockVirtualizedListWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const itemKey = 'item-1';
|
||||
const mockListContext = {
|
||||
toggleItem,
|
||||
registerClickCallback: vi.fn((key, id, cb) => {
|
||||
if (key === itemKey && id === 'toggle-call-1') {
|
||||
registeredCallback = cb;
|
||||
}
|
||||
}),
|
||||
unregisterClickCallback: vi.fn(),
|
||||
registerInteractivity: vi.fn(),
|
||||
setItemState: vi.fn(),
|
||||
getItemState: vi.fn(),
|
||||
isItemToggled: vi.fn().mockReturnValue(false),
|
||||
registerClickableArea: vi.fn(),
|
||||
unregisterClickableArea: vi.fn(),
|
||||
};
|
||||
|
||||
return (
|
||||
<VirtualizedListContext.Provider
|
||||
value={
|
||||
mockListContext as unknown as React.ContextType<
|
||||
typeof VirtualizedListContext
|
||||
>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const { waitUntilReady } = await renderWithProviders(
|
||||
<MockVirtualizedListWrapper>
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
callId="call-1"
|
||||
itemKey="item-1"
|
||||
/>
|
||||
</MockVirtualizedListWrapper>,
|
||||
{
|
||||
toolActions: {
|
||||
toggleExpansion,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => expect(registeredCallback).toBeDefined());
|
||||
|
||||
// Trigger the registered callback manually (simulating VirtualizedList behavior)
|
||||
if (registeredCallback) {
|
||||
registeredCallback();
|
||||
}
|
||||
|
||||
expect(toggleItem).toHaveBeenCalledWith('item-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual Regression', () => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useMemo, useContext, useCallback, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type FileDiff,
|
||||
@@ -32,13 +32,14 @@ import {
|
||||
isNewFile,
|
||||
parseDiffWithLineNumbers,
|
||||
} from './DiffRenderer.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
|
||||
const PAYLOAD_MARGIN_LEFT = 6;
|
||||
const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
|
||||
@@ -46,6 +47,8 @@ const PAYLOAD_SCROLL_GUTTER = 4;
|
||||
const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
|
||||
|
||||
interface DenseToolMessageProps extends IndividualToolCallDisplay {
|
||||
itemKey?: string;
|
||||
groupKey?: string;
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
@@ -260,6 +263,8 @@ function getGenericSuccessData(
|
||||
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const {
|
||||
itemKey,
|
||||
groupKey,
|
||||
callId,
|
||||
name,
|
||||
status,
|
||||
@@ -274,15 +279,45 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
// Handle optional context members
|
||||
const [localIsExpanded, setLocalIsExpanded] = useState(false);
|
||||
const isExpanded = isExpandedInContext
|
||||
? isExpandedInContext(callId)
|
||||
: localIsExpanded;
|
||||
const effectiveItemKey = groupKey ?? itemKey;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const toggleRef = useRef<DOMElement>(null);
|
||||
// Determine expansion state based on list context or fallback to tool actions
|
||||
const isExpanded = useMemo(() => {
|
||||
const isExpandedGlobally = isExpandedInContext
|
||||
? isExpandedInContext(callId)
|
||||
: false;
|
||||
if (effectiveItemKey && virtualizedListContext) {
|
||||
return (
|
||||
virtualizedListContext.isItemToggled(effectiveItemKey) ||
|
||||
isExpandedGlobally
|
||||
);
|
||||
}
|
||||
return isExpandedGlobally;
|
||||
}, [effectiveItemKey, virtualizedListContext, isExpandedInContext, callId]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (effectiveItemKey && virtualizedListContext?.toggleItem) {
|
||||
virtualizedListContext.toggleItem(effectiveItemKey);
|
||||
} else if (toggleExpansion) {
|
||||
toggleExpansion(callId);
|
||||
}
|
||||
}, [effectiveItemKey, virtualizedListContext, toggleExpansion, callId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (virtualizedListContext && effectiveItemKey) {
|
||||
virtualizedListContext.registerInteractivity(effectiveItemKey, {
|
||||
click: true,
|
||||
});
|
||||
}
|
||||
}, [virtualizedListContext, effectiveItemKey]);
|
||||
|
||||
const clickableProps = useVirtualizedListClick(
|
||||
effectiveItemKey,
|
||||
`toggle-${callId}`,
|
||||
handleToggle,
|
||||
);
|
||||
|
||||
// Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
|
||||
const diff = useMemo((): FileDiff | undefined => {
|
||||
@@ -301,25 +336,6 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
return undefined;
|
||||
}, [resultDisplay, confirmationDetails]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !isExpanded;
|
||||
if (!next) {
|
||||
setIsFocused(false);
|
||||
} else {
|
||||
setIsFocused(true);
|
||||
}
|
||||
|
||||
if (toggleExpansion) {
|
||||
toggleExpansion(callId);
|
||||
} else {
|
||||
setLocalIsExpanded(next);
|
||||
}
|
||||
};
|
||||
|
||||
useMouseClick(toggleRef, handleToggle, {
|
||||
isActive: isAlternateBuffer && !!diff,
|
||||
});
|
||||
|
||||
// State-to-View Coordination
|
||||
const viewParts = useMemo((): ViewParts => {
|
||||
if (diff) {
|
||||
@@ -449,7 +465,12 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
|
||||
<Box
|
||||
ref={clickableProps.ref}
|
||||
marginLeft={2}
|
||||
flexDirection="row"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Box flexDirection="row" flexShrink={1}>
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<Box maxWidth={25} flexShrink={0} flexGrow={0}>
|
||||
@@ -463,12 +484,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
</Box>
|
||||
|
||||
{summary && (
|
||||
<Box
|
||||
key="tool-summary"
|
||||
ref={isAlternateBuffer && diff ? toggleRef : undefined}
|
||||
marginLeft={1}
|
||||
flexGrow={0}
|
||||
>
|
||||
<Box key="tool-summary" marginLeft={1} flexGrow={0}>
|
||||
{summary}
|
||||
</Box>
|
||||
)}
|
||||
@@ -489,23 +505,18 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
borderColor={theme.border.default}
|
||||
borderDimColor={true}
|
||||
maxWidth={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
PAYLOAD_MAX_WIDTH + PAYLOAD_BORDER_CHROME_WIDTH,
|
||||
terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
)}
|
||||
>
|
||||
<ScrollableList
|
||||
itemKey={itemKey}
|
||||
data={diffLines}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
hasFocus={isFocused}
|
||||
width={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth -
|
||||
PAYLOAD_MARGIN_LEFT -
|
||||
PAYLOAD_BORDER_CHROME_WIDTH -
|
||||
PAYLOAD_SCROLL_GUTTER,
|
||||
)}
|
||||
hasFocus={false}
|
||||
width="100%"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList } from '../shared/VirtualizedList.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import { Box } from 'ink';
|
||||
import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('DenseToolMessage Interactivity in VirtualizedList', () => {
|
||||
const keyExtractor = (item: { id: string }) => item.id;
|
||||
|
||||
it('toggles expansion when header is clicked in a VirtualizedList', async () => {
|
||||
const data = [{ id: '1' }];
|
||||
const diffResult = {
|
||||
fileName: 'test.ts',
|
||||
filePath: 'test.ts',
|
||||
fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new',
|
||||
diffStat: { model_added_lines: 1, model_removed_lines: 1 },
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
// We need to monitor if toggleItem is called on the list context
|
||||
// Actually, VirtualizedList handles its own state.
|
||||
// We can verify that it renders the payload after click.
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={20} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={() => (
|
||||
<DenseToolMessage
|
||||
callId="call-1"
|
||||
itemKey="1-tool-call-1"
|
||||
groupKey="1"
|
||||
name="edit"
|
||||
status={CoreToolCallStatus.Success}
|
||||
resultDisplay={
|
||||
diffResult as unknown as React.ComponentProps<
|
||||
typeof DenseToolMessage
|
||||
>['resultDisplay']
|
||||
}
|
||||
terminalWidth={80}
|
||||
description="test"
|
||||
confirmationDetails={undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
mouseEventsEnabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Initially it should be collapsed (no payload shown because of alternate buffer mode)
|
||||
expect(lastFrame()).toContain('edit');
|
||||
expect(lastFrame()).toContain('test.ts');
|
||||
expect(lastFrame()).not.toContain('new');
|
||||
|
||||
// Click on the first line (the header), avoiding the left margin
|
||||
await simulateClick(10, 1);
|
||||
|
||||
// Now it should be expanded and show the diff payload
|
||||
await waitFor(() => expect(lastFrame()).toContain('new'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('wakes up static DenseToolMessage and toggles on click', async () => {
|
||||
const data = [{ id: '1' }];
|
||||
const diffResult = {
|
||||
fileName: 'test.ts',
|
||||
filePath: 'test.ts',
|
||||
fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new',
|
||||
diffStat: { model_added_lines: 1, model_removed_lines: 1 },
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={20} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={() => (
|
||||
<DenseToolMessage
|
||||
callId="call-1"
|
||||
itemKey="1-tool-call-1"
|
||||
groupKey="1"
|
||||
name="edit"
|
||||
status={CoreToolCallStatus.Success}
|
||||
resultDisplay={
|
||||
diffResult as unknown as React.ComponentProps<
|
||||
typeof DenseToolMessage
|
||||
>['resultDisplay']
|
||||
}
|
||||
terminalWidth={80}
|
||||
description="test"
|
||||
confirmationDetails={undefined}
|
||||
/>
|
||||
)}
|
||||
isStaticItem={() => true} // Force static rendering
|
||||
/>
|
||||
</Box>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
mouseEventsEnabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Static item should still show the header
|
||||
expect(lastFrame()).toContain('edit');
|
||||
expect(lastFrame()).not.toContain('new');
|
||||
|
||||
// Click to wake up and toggle
|
||||
await simulateClick(10, 1);
|
||||
|
||||
// Should wake up and expand
|
||||
await waitFor(() => expect(lastFrame()).toContain('new'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -20,6 +21,7 @@ interface GeminiMessageProps {
|
||||
|
||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text,
|
||||
itemKey,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -37,6 +39,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
itemKey={itemKey}
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -24,6 +25,7 @@ interface GeminiMessageContentProps {
|
||||
*/
|
||||
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text,
|
||||
itemKey,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -35,6 +37,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||
<MarkdownDisplay
|
||||
itemKey={itemKey}
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, Fragment } from 'react';
|
||||
import { useMemo, Fragment, useContext } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
TOOL_RESULT_STATIC_HEIGHT,
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
|
||||
const COMPACT_OUTPUT_ALLOWLIST = new Set([
|
||||
EDIT_DISPLAY_NAME,
|
||||
@@ -94,6 +95,7 @@ export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
|
||||
};
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
itemKey?: string;
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
@@ -108,6 +110,7 @@ interface ToolGroupMessageProps {
|
||||
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
|
||||
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
itemKey,
|
||||
item,
|
||||
toolCalls: allToolCalls,
|
||||
availableTerminalHeight,
|
||||
@@ -141,6 +144,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
} = useUIState();
|
||||
|
||||
const config = useConfig();
|
||||
const { registerInteractivity } = useContext(VirtualizedListContext) ?? {};
|
||||
|
||||
if (itemKey && registerInteractivity) {
|
||||
registerInteractivity(itemKey, { click: true, scroll: true });
|
||||
}
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
@@ -425,13 +433,18 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const tool = group;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
const uniqueItemKey = itemKey
|
||||
? `${itemKey}-tool-${tool.callId}`
|
||||
: undefined;
|
||||
|
||||
const commonProps = {
|
||||
...tool,
|
||||
itemKey: uniqueItemKey,
|
||||
groupKey: itemKey,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
isFirst: isCompact ? false : isFirstProp,
|
||||
isFirst: isFirstProp,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
export type { TextEmphasis };
|
||||
|
||||
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
itemKey?: string;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
emphasis?: TextEmphasis;
|
||||
@@ -44,6 +45,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
|
||||
export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
callId,
|
||||
itemKey,
|
||||
name,
|
||||
description,
|
||||
resultDisplay,
|
||||
@@ -139,6 +141,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
/>
|
||||
)}
|
||||
<ToolResultDisplay
|
||||
itemKey={itemKey}
|
||||
resultDisplay={resultDisplay}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
|
||||
@@ -22,13 +22,14 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { FixedScrollableList } from '../shared/FixedScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
|
||||
export interface ToolResultDisplayProps {
|
||||
itemKey?: string;
|
||||
resultDisplay: string | object | undefined;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -44,6 +45,7 @@ interface FileDiffResult {
|
||||
}
|
||||
|
||||
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
itemKey,
|
||||
resultDisplay,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -194,6 +196,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Scrollable
|
||||
itemKey={itemKey}
|
||||
width={childWidth}
|
||||
maxHeight={effectiveMaxHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
@@ -213,12 +216,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = resultDisplay as AnsiOutput;
|
||||
|
||||
// Calculate list height: if not constrained, use full data length.
|
||||
// If constrained (e.g. alternate buffer), limit to available height
|
||||
// to ensure virtualization works and fits within the viewport.
|
||||
const listHeight = !constrainHeight
|
||||
? data.length
|
||||
: Math.min(data.length, limit);
|
||||
// In alternate buffer, always constrain to limit to ensure virtualization works and fits viewport.
|
||||
const listHeight = isAlternateBuffer
|
||||
? Math.min(data.length, limit)
|
||||
: !constrainHeight
|
||||
? data.length
|
||||
: Math.min(data.length, limit);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
const initialScrollIndex =
|
||||
@@ -226,13 +229,13 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<ScrollableList
|
||||
<FixedScrollableList
|
||||
itemKey={itemKey}
|
||||
width={childWidth}
|
||||
containerHeight={listHeight}
|
||||
maxHeight={listHeight}
|
||||
data={data}
|
||||
renderItem={renderVirtualizedAnsiLine}
|
||||
estimatedItemHeight={() => 1}
|
||||
fixedItemHeight={true}
|
||||
itemHeight={1}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={initialScrollIndex}
|
||||
hasFocus={hasFocus}
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('ToolMessage Sticky Header Regression', () => {
|
||||
|
||||
// Scroll further so tool-1 is completely gone and tool-2's header should be stuck
|
||||
await act(async () => {
|
||||
listRef?.scrollBy(17);
|
||||
listRef?.scrollBy(15);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
CoreToolCallStatus,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
import { Box, type DOMElement, getBoundingBox } from 'ink';
|
||||
import { useMouse } from '../../contexts/MouseContext.js';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
describe('<TopicMessage />', () => {
|
||||
const baseArgs = {
|
||||
@@ -30,21 +35,86 @@ describe('<TopicMessage />', () => {
|
||||
isExpanded?: (callId: string) => boolean;
|
||||
toggleExpansion?: (callId: string) => void;
|
||||
},
|
||||
) =>
|
||||
renderWithProviders(
|
||||
<TopicMessage
|
||||
args={args}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
callId="test-topic"
|
||||
name={UPDATE_TOPIC_TOOL_NAME}
|
||||
description="Updating topic"
|
||||
status={CoreToolCallStatus.Success}
|
||||
confirmationDetails={undefined}
|
||||
resultDisplay={undefined}
|
||||
/>,
|
||||
virtualizedListProps?: {
|
||||
itemKey?: string;
|
||||
},
|
||||
) => {
|
||||
const defaultItemKey = virtualizedListProps?.itemKey || 'test-topic-key';
|
||||
|
||||
const MockVirtualizedListWrapper: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const callbacks = useRef(new Map<string, () => void>());
|
||||
|
||||
const mockListContext = {
|
||||
registerInteractivity: vi.fn(),
|
||||
setItemState: vi.fn(),
|
||||
getItemState: vi.fn(),
|
||||
isItemToggled: vi.fn().mockReturnValue(false),
|
||||
toggleItem: vi.fn(),
|
||||
registerClickCallback: vi.fn((key, id, cb) => {
|
||||
if (key === defaultItemKey) callbacks.current.set(id, cb);
|
||||
}),
|
||||
unregisterClickCallback: vi.fn((key, id) => {
|
||||
if (key === defaultItemKey) callbacks.current.delete(id);
|
||||
}),
|
||||
registerClickableArea: vi.fn(),
|
||||
unregisterClickableArea: vi.fn(),
|
||||
toggledKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
const handleMouse = useCallback(
|
||||
(event: { name: string; col: number; row: number }) => {
|
||||
if (event.name === 'left-press' && containerRef.current) {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: elHeight,
|
||||
} = getBoundingBox(containerRef.current);
|
||||
const mouseX = event.col - 1;
|
||||
const mouseY = event.row - 1;
|
||||
if (
|
||||
mouseX >= x &&
|
||||
mouseX < x + width &&
|
||||
mouseY >= y &&
|
||||
mouseY < y + elHeight
|
||||
) {
|
||||
const cb = callbacks.current.get('toggle');
|
||||
if (cb) cb();
|
||||
}
|
||||
}
|
||||
},
|
||||
[callbacks],
|
||||
);
|
||||
useMouse(handleMouse, { isActive: true });
|
||||
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={mockListContext}>
|
||||
<Box ref={containerRef}>{children}</Box>
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
return renderWithProviders(
|
||||
<MockVirtualizedListWrapper>
|
||||
<TopicMessage
|
||||
args={args}
|
||||
itemKey={defaultItemKey}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
callId="test-topic"
|
||||
name={UPDATE_TOPIC_TOOL_NAME}
|
||||
description="Updating topic"
|
||||
status={CoreToolCallStatus.Success}
|
||||
confirmationDetails={undefined}
|
||||
resultDisplay={undefined}
|
||||
/>
|
||||
</MockVirtualizedListWrapper>,
|
||||
{ toolActions, mouseEventsEnabled: true },
|
||||
);
|
||||
};
|
||||
|
||||
it('renders title and intent by default (collapsed)', async () => {
|
||||
const { lastFrame } = await renderTopic(baseArgs, 40);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useId, useRef, useCallback } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useEffect, useId, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
@@ -18,12 +18,14 @@ import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
|
||||
interface TopicMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
isExpandable?: boolean;
|
||||
// TopicMessage is only interactive when rendered inside VirtualizedList.
|
||||
itemKey?: string;
|
||||
}
|
||||
|
||||
export const isTopicTool = (name: string): boolean =>
|
||||
@@ -34,6 +36,7 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
args,
|
||||
availableTerminalHeight,
|
||||
isExpandable = true,
|
||||
itemKey,
|
||||
}) => {
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
|
||||
@@ -47,7 +50,6 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
const overflowActions = useOverflowActions();
|
||||
const uniqueId = useId();
|
||||
const overflowId = `topic-${uniqueId}`;
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const rawTitle = args?.[TOPIC_PARAM_TITLE];
|
||||
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
|
||||
@@ -75,9 +77,14 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
}
|
||||
}, [toggleExpansion, hasExtraSummary, callId]);
|
||||
|
||||
useMouseClick(containerRef, handleToggle, {
|
||||
isActive: isExpandable && hasExtraSummary,
|
||||
});
|
||||
const clickableProps = useVirtualizedListClick(
|
||||
itemKey,
|
||||
'toggle',
|
||||
handleToggle,
|
||||
{
|
||||
isActive: isExpandable && hasExtraSummary,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only register if there is more content (summary) and it's currently hidden
|
||||
@@ -95,7 +102,7 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
}, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]);
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} flexDirection="column" marginLeft={2}>
|
||||
<Box ref={clickableProps.ref} flexDirection="column" marginLeft={2}>
|
||||
<Box flexDirection="row" flexWrap="wrap">
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{title || 'Topic'}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ █
|
||||
│ ✓ Shell Command Description for Shell Command │ █
|
||||
│ ✓ Shell Command Description for Shell Command │
|
||||
│ │
|
||||
│ shell-01 │
|
||||
│ shell-02 │
|
||||
@@ -10,10 +10,10 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
|
||||
`;
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command Description for Shell Command │ ▄
|
||||
│────────────────────────────────────────────────────────────────────────│ █
|
||||
│ shell-06 │ ▀
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ ▄
|
||||
│ ✓ Shell Command Description for Shell Command │ ▀
|
||||
│────────────────────────────────────────────────────────────────────────│
|
||||
│ shell-06 │
|
||||
│ shell-07 │
|
||||
"
|
||||
`;
|
||||
@@ -28,8 +28,8 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa
|
||||
`;
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 2`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-1 Description for tool-1 │ █
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ ▄
|
||||
│ ✓ tool-1 Description for tool-1 │ ▀
|
||||
│────────────────────────────────────────────────────────────────────────│
|
||||
│ c1-06 │
|
||||
│ c1-07 │
|
||||
@@ -38,9 +38,9 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 3`] = `
|
||||
"│ │
|
||||
│ ✓ tool-2 Description for tool-2 │
|
||||
│────────────────────────────────────────────────────────────────────────│
|
||||
│ c2-10 │
|
||||
╰────────────────────────────────────────────────────────────────────────╯ █
|
||||
│ ✓ tool-2 Description for tool-2 │ ▄
|
||||
│────────────────────────────────────────────────────────────────────────│ ▀
|
||||
│ c2-08 │
|
||||
│ c2-09 │
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
FixedVirtualizedList,
|
||||
type FixedVirtualizedListRef,
|
||||
type FixedVirtualizedListProps,
|
||||
SCROLL_TO_ITEM_END,
|
||||
} from './FixedVirtualizedList.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface FixedScrollableListProps<T> extends FixedVirtualizedListProps<T> {
|
||||
itemKey?: string;
|
||||
hasFocus: boolean;
|
||||
width: number;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
}
|
||||
|
||||
export type FixedScrollableListRef<T> = FixedVirtualizedListRef<T>;
|
||||
|
||||
function FixedScrollableList<T>(
|
||||
props: FixedScrollableListProps<T>,
|
||||
ref: React.Ref<FixedScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const {
|
||||
itemKey,
|
||||
hasFocus,
|
||||
width,
|
||||
maxHeight,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
} = props;
|
||||
const fixedVirtualizedListRef = useRef<FixedVirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const restoredTop = virtualizedListContext.getItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
);
|
||||
if (typeof restoredTop === 'number') {
|
||||
fixedVirtualizedListRef.current?.scrollTo(restoredTop);
|
||||
}
|
||||
}
|
||||
}, [itemKey, virtualizedListContext]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const top = fixedVirtualizedListRef.current?.getScrollState().scrollTop;
|
||||
if (top !== undefined) {
|
||||
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta) => fixedVirtualizedListRef.current?.scrollBy(delta),
|
||||
scrollTo: (offset) => fixedVirtualizedListRef.current?.scrollTo(offset),
|
||||
scrollToEnd: () => fixedVirtualizedListRef.current?.scrollToEnd(),
|
||||
scrollToIndex: (params) =>
|
||||
fixedVirtualizedListRef.current?.scrollToIndex(params),
|
||||
scrollToItem: (params) =>
|
||||
fixedVirtualizedListRef.current?.scrollToItem(params),
|
||||
getScrollIndex: () =>
|
||||
fixedVirtualizedListRef.current?.getScrollIndex() ?? 0,
|
||||
getScrollState: () =>
|
||||
fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const getScrollState = useCallback(
|
||||
() =>
|
||||
fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollBy = useCallback((delta: number) => {
|
||||
fixedVirtualizedListRef.current?.scrollBy(delta);
|
||||
}, []);
|
||||
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
useAnimatedScrollbar(hasFocus, scrollBy);
|
||||
|
||||
const smoothScrollState = useRef<{
|
||||
active: boolean;
|
||||
start: number;
|
||||
from: number;
|
||||
to: number;
|
||||
duration: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
}>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null });
|
||||
|
||||
const stopSmoothScroll = useCallback(() => {
|
||||
if (smoothScrollState.current.timer) {
|
||||
clearInterval(smoothScrollState.current.timer);
|
||||
smoothScrollState.current.timer = null;
|
||||
}
|
||||
smoothScrollState.current.active = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
||||
|
||||
const smoothScrollTo = useCallback(
|
||||
(
|
||||
targetScrollTop: number,
|
||||
duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200,
|
||||
) => {
|
||||
stopSmoothScroll();
|
||||
|
||||
const scrollState = fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
};
|
||||
const {
|
||||
scrollTop: rawStartScrollTop,
|
||||
scrollHeight,
|
||||
innerHeight,
|
||||
} = scrollState;
|
||||
|
||||
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
|
||||
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
|
||||
|
||||
let effectiveTarget = targetScrollTop;
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
effectiveTarget = maxScrollTop;
|
||||
}
|
||||
|
||||
const clampedTarget = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollTop, effectiveTarget),
|
||||
);
|
||||
|
||||
if (duration === 0) {
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
||||
}
|
||||
flashScrollbar();
|
||||
return;
|
||||
}
|
||||
|
||||
smoothScrollState.current = {
|
||||
active: true,
|
||||
start: Date.now(),
|
||||
from: startScrollTop,
|
||||
to: clampedTarget,
|
||||
duration,
|
||||
timer: setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - smoothScrollState.current.start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-in-out
|
||||
const t = progress;
|
||||
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
|
||||
const current =
|
||||
smoothScrollState.current.from +
|
||||
(smoothScrollState.current.to - smoothScrollState.current.from) *
|
||||
ease;
|
||||
|
||||
if (progress >= 1) {
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
fixedVirtualizedListRef.current?.scrollTo(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
stopSmoothScroll();
|
||||
flashScrollbar();
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
}, ANIMATION_FRAME_DURATION_MS),
|
||||
};
|
||||
},
|
||||
[stopSmoothScroll, flashScrollbar],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.SCROLL_UP](key)) {
|
||||
stopSmoothScroll();
|
||||
scrollByWithAnimation(-1);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
|
||||
stopSmoothScroll();
|
||||
scrollByWithAnimation(1);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.PAGE_UP](key) ||
|
||||
keyMatchers[Command.PAGE_DOWN](key)
|
||||
) {
|
||||
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
|
||||
const scrollState = getScrollState();
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
scrollState.scrollHeight - scrollState.innerHeight,
|
||||
);
|
||||
const current = smoothScrollState.current.active
|
||||
? smoothScrollState.current.to
|
||||
: Math.min(scrollState.scrollTop, maxScroll);
|
||||
const innerHeight = scrollState.innerHeight;
|
||||
smoothScrollTo(current + direction * innerHeight);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
|
||||
smoothScrollTo(0);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_END](key)) {
|
||||
smoothScrollTo(SCROLL_TO_ITEM_END);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: hasFocus },
|
||||
);
|
||||
|
||||
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
||||
|
||||
const scrollableEntry = useMemo(
|
||||
() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ref: containerRef as React.RefObject<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
scrollTo: smoothScrollTo,
|
||||
hasFocus: hasFocusCallback,
|
||||
flashScrollbar,
|
||||
}),
|
||||
[
|
||||
getScrollState,
|
||||
hasFocusCallback,
|
||||
flashScrollbar,
|
||||
scrollByWithAnimation,
|
||||
smoothScrollTo,
|
||||
],
|
||||
);
|
||||
|
||||
useScrollable(scrollableEntry, true);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
<FixedVirtualizedList
|
||||
ref={fixedVirtualizedListRef}
|
||||
{...props}
|
||||
scrollbar={scrollbar}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
stableScrollback={stableScrollback}
|
||||
maxScrollbackLength={maxScrollbackLength}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const FixedScrollableListWithForwardRef = forwardRef(FixedScrollableList) as <
|
||||
T,
|
||||
>(
|
||||
props: FixedScrollableListProps<T> & {
|
||||
ref?: React.Ref<FixedScrollableListRef<T>>;
|
||||
},
|
||||
) => React.ReactElement;
|
||||
|
||||
export { FixedScrollableListWithForwardRef as FixedScrollableList };
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import {
|
||||
FixedVirtualizedList,
|
||||
SCROLL_TO_ITEM_END,
|
||||
} from './FixedVirtualizedList.js';
|
||||
|
||||
describe('<FixedVirtualizedList />', () => {
|
||||
const renderList = (data: string[]) => (
|
||||
<Box height={5} width={80}>
|
||||
<FixedVirtualizedList
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
itemHeight={1}
|
||||
keyExtractor={(item) => item}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
|
||||
width={80}
|
||||
maxHeight={5}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
it('sticks to the bottom when data grows', async () => {
|
||||
const initialData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
|
||||
renderList(initialData),
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Item 9');
|
||||
|
||||
const newData = [...initialData, 'Item 10', 'Item 11'];
|
||||
await act(async () => {
|
||||
rerender(renderList(newData));
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Item 11');
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
memo,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
|
||||
import { Box, StaticRender } from 'ink';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export type FixedVirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
itemHeight: number;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
targetScrollIndex?: number;
|
||||
backgroundColor?: string;
|
||||
scrollbarThumbColor?: string;
|
||||
renderStatic?: boolean;
|
||||
isStaticItem?: (item: T, index: number) => boolean;
|
||||
width: number;
|
||||
overflowToBackbuffer?: boolean;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
maxHeight: number;
|
||||
maxScrollbackLength?: number;
|
||||
};
|
||||
|
||||
export type FixedVirtualizedListRef<T> = {
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
scrollToEnd: () => void;
|
||||
scrollToIndex: (params: {
|
||||
index: number;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => void;
|
||||
scrollToItem: (params: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => void;
|
||||
getScrollIndex: () => number;
|
||||
getScrollState: () => {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
innerHeight: number;
|
||||
};
|
||||
};
|
||||
|
||||
const FixedVirtualizedListItem = memo(
|
||||
({
|
||||
content,
|
||||
shouldBeStatic,
|
||||
width,
|
||||
itemKey,
|
||||
}: {
|
||||
content: React.ReactElement;
|
||||
shouldBeStatic: boolean;
|
||||
width: number;
|
||||
itemKey: string;
|
||||
}) => (
|
||||
<Box width="100%" flexDirection="column" flexShrink={0}>
|
||||
{shouldBeStatic ? (
|
||||
<StaticRender width={width} key={itemKey + '-static-' + width}>
|
||||
{() => content}
|
||||
</StaticRender>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
);
|
||||
|
||||
FixedVirtualizedListItem.displayName = 'FixedVirtualizedListItem';
|
||||
|
||||
function FixedVirtualizedList<T>(
|
||||
props: FixedVirtualizedListProps<T>,
|
||||
ref: React.Ref<FixedVirtualizedListRef<T>>,
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
renderItem,
|
||||
itemHeight,
|
||||
keyExtractor,
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
renderStatic,
|
||||
isStaticItem,
|
||||
width,
|
||||
overflowToBackbuffer,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
maxScrollbackLength,
|
||||
maxHeight,
|
||||
} = props;
|
||||
|
||||
const [scrollAnchor, setScrollAnchor] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
return {
|
||||
index: data.length > 0 ? data.length - 1 : 0,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof initialScrollIndex === 'number') {
|
||||
return {
|
||||
index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)),
|
||||
offset: initialScrollOffsetInIndex ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof props.targetScrollIndex === 'number') {
|
||||
return {
|
||||
index: props.targetScrollIndex,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { index: 0, offset: 0 };
|
||||
});
|
||||
|
||||
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
return scrollToEnd;
|
||||
});
|
||||
|
||||
const totalHeight = data.length * itemHeight;
|
||||
const scrollableContainerHeight = maxHeight;
|
||||
const isInitialScrollSet = useRef(false);
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(scrollTop: number): { index: number; offset: number } => {
|
||||
const index = Math.max(
|
||||
0,
|
||||
Math.min(data.length - 1, Math.floor(scrollTop / itemHeight)),
|
||||
);
|
||||
if (data.length === 0) {
|
||||
return { index: 0, offset: 0 };
|
||||
}
|
||||
return { index, offset: scrollTop - index * itemHeight };
|
||||
},
|
||||
[data.length, itemHeight],
|
||||
);
|
||||
|
||||
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
|
||||
props.targetScrollIndex,
|
||||
);
|
||||
const prevDataLength = useRef(data.length);
|
||||
const previousDataLength = prevDataLength.current;
|
||||
|
||||
if (
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
props.targetScrollIndex !== prevTargetScrollIndex &&
|
||||
data.length > 0) ||
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
previousDataLength === 0 &&
|
||||
data.length > 0)
|
||||
) {
|
||||
if (props.targetScrollIndex !== prevTargetScrollIndex) {
|
||||
setPrevTargetScrollIndex(props.targetScrollIndex);
|
||||
}
|
||||
setIsStickingToBottom(false);
|
||||
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
|
||||
}
|
||||
|
||||
const rawStateActualScrollTop = (() => {
|
||||
const offset = scrollAnchor.index * itemHeight;
|
||||
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
return offset + scrollAnchor.offset;
|
||||
})();
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const stateActualScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScroll, rawStateActualScrollTop),
|
||||
);
|
||||
|
||||
const prevTotalHeight = useRef(totalHeight);
|
||||
const prevScrollTop = useRef(rawStateActualScrollTop);
|
||||
const prevContainerHeight = useRef(scrollableContainerHeight);
|
||||
|
||||
// Render-time state derivation to avoid useEffect for static rendering
|
||||
let currentScrollAnchor = scrollAnchor;
|
||||
let currentIsStickingToBottom = isStickingToBottom;
|
||||
|
||||
const contentPreviouslyFit =
|
||||
prevTotalHeight.current <= prevContainerHeight.current;
|
||||
const wasScrolledToBottomPixels =
|
||||
prevScrollTop.current >=
|
||||
prevTotalHeight.current - prevContainerHeight.current - 1;
|
||||
|
||||
// Crucial fix: we were previously only evaluating wasAtBottom against rawStateActualScrollTop *if* it was at bottom *last* frame.
|
||||
// But if the content just exceeded the container height, wasScrolledToBottomPixels is false, but contentPreviouslyFit is true.
|
||||
// If it previously fit, it implicitly means we should stick to the bottom if the new height exceeds the container.
|
||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||
|
||||
if (
|
||||
wasAtBottom &&
|
||||
(rawStateActualScrollTop >= prevScrollTop.current || contentPreviouslyFit)
|
||||
) {
|
||||
if (!currentIsStickingToBottom) {
|
||||
currentIsStickingToBottom = true;
|
||||
if (scrollAnchor === currentScrollAnchor) {
|
||||
// Avoid infinite loop if we already updated state
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listGrew = data.length > previousDataLength;
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
const shouldAutoScroll = props.targetScrollIndex === undefined;
|
||||
|
||||
if (
|
||||
shouldAutoScroll &&
|
||||
((listGrew && (currentIsStickingToBottom || wasAtBottom)) ||
|
||||
(currentIsStickingToBottom && containerChanged))
|
||||
) {
|
||||
const newIndex = data.length > 0 ? data.length - 1 : 0;
|
||||
if (
|
||||
currentScrollAnchor.index !== newIndex ||
|
||||
currentScrollAnchor.offset !== SCROLL_TO_ITEM_END
|
||||
) {
|
||||
currentScrollAnchor = {
|
||||
index: newIndex,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
}
|
||||
if (!currentIsStickingToBottom) {
|
||||
currentIsStickingToBottom = true;
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
} else if (
|
||||
(currentScrollAnchor.index >= data.length ||
|
||||
stateActualScrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
data.length > 0
|
||||
) {
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const newAnchor = getAnchorForScrollTop(newScrollTop);
|
||||
if (
|
||||
currentScrollAnchor.index !== newAnchor.index ||
|
||||
currentScrollAnchor.offset !== newAnchor.offset
|
||||
) {
|
||||
currentScrollAnchor = newAnchor;
|
||||
setScrollAnchor(newAnchor);
|
||||
}
|
||||
} else if (data.length === 0) {
|
||||
if (currentScrollAnchor.index !== 0 || currentScrollAnchor.offset !== 0) {
|
||||
currentScrollAnchor = { index: 0, offset: 0 };
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial scroll setup during render
|
||||
if (
|
||||
!isInitialScrollSet.current &&
|
||||
data.length > 0 &&
|
||||
totalHeight > 0 &&
|
||||
scrollableContainerHeight > 0
|
||||
) {
|
||||
if (props.targetScrollIndex !== undefined) {
|
||||
isInitialScrollSet.current = true;
|
||||
} else if (typeof initialScrollIndex === 'number') {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
currentScrollAnchor = {
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
currentIsStickingToBottom = true;
|
||||
setIsStickingToBottom(true);
|
||||
isInitialScrollSet.current = true;
|
||||
} else {
|
||||
const index = Math.max(
|
||||
0,
|
||||
Math.min(data.length - 1, initialScrollIndex),
|
||||
);
|
||||
const offset = initialScrollOffsetInIndex ?? 0;
|
||||
const newScrollTop = index * itemHeight + offset;
|
||||
|
||||
const clampedScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
|
||||
);
|
||||
|
||||
currentScrollAnchor = getAnchorForScrollTop(clampedScrollTop);
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
isInitialScrollSet.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After all derived state updates, update refs for the next render
|
||||
prevDataLength.current = data.length;
|
||||
prevTotalHeight.current = totalHeight;
|
||||
|
||||
const rawDerivedActualScrollTop = (() => {
|
||||
const offset = currentScrollAnchor.index * itemHeight;
|
||||
if (currentScrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
return offset + currentScrollAnchor.offset;
|
||||
})();
|
||||
const derivedActualScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScroll, rawDerivedActualScrollTop),
|
||||
);
|
||||
|
||||
prevScrollTop.current = rawDerivedActualScrollTop;
|
||||
prevContainerHeight.current = scrollableContainerHeight;
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.floor(derivedActualScrollTop / itemHeight) - 1,
|
||||
);
|
||||
const viewHeightForEndIndex =
|
||||
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
|
||||
|
||||
const maxEndIndex = data.length - 1;
|
||||
const endIndex = Math.min(
|
||||
maxEndIndex,
|
||||
Math.ceil((derivedActualScrollTop + viewHeightForEndIndex) / itemHeight),
|
||||
);
|
||||
|
||||
const culledHeight = useMemo(() => {
|
||||
if (
|
||||
overflowToBackbuffer &&
|
||||
typeof maxScrollbackLength === 'number' &&
|
||||
maxScrollbackLength > 0
|
||||
) {
|
||||
// Keep maxScrollbackLength items before the viewport.
|
||||
// We add 1 to startIndex to account for the 1-item overscan it includes.
|
||||
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
|
||||
return targetIndex * itemHeight;
|
||||
}
|
||||
return 0;
|
||||
}, [overflowToBackbuffer, maxScrollbackLength, startIndex, itemHeight]);
|
||||
|
||||
const scrollTop = currentIsStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Math.max(0, derivedActualScrollTop - culledHeight);
|
||||
|
||||
const renderRangeStart = (() => {
|
||||
if (renderStatic) return 0;
|
||||
if (overflowToBackbuffer) {
|
||||
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
|
||||
// Render from the culled boundary.
|
||||
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
|
||||
return targetIndex;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return startIndex;
|
||||
})();
|
||||
|
||||
const renderRangeEnd = renderStatic ? maxEndIndex : endIndex;
|
||||
|
||||
const topSpacerHeight = Math.max(
|
||||
0,
|
||||
renderRangeStart * itemHeight - culledHeight,
|
||||
);
|
||||
const bottomSpacerHeight = renderStatic
|
||||
? 0
|
||||
: totalHeight - (renderRangeEnd + 1) * itemHeight;
|
||||
|
||||
const renderedItems = useMemo(() => {
|
||||
const items = [];
|
||||
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
const isOutsideViewport = i < startIndex || i > endIndex;
|
||||
const shouldBeStatic =
|
||||
(renderStatic === true && isOutsideViewport) ||
|
||||
isStaticItem?.(item, i) === true;
|
||||
|
||||
const content = renderItem({ item, index: i });
|
||||
const key = keyExtractor(item, i);
|
||||
|
||||
items.push(
|
||||
<FixedVirtualizedListItem
|
||||
key={key}
|
||||
itemKey={key}
|
||||
content={content}
|
||||
shouldBeStatic={shouldBeStatic}
|
||||
width={width}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [
|
||||
renderRangeStart,
|
||||
renderRangeEnd,
|
||||
data,
|
||||
startIndex,
|
||||
endIndex,
|
||||
renderStatic,
|
||||
isStaticItem,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
width,
|
||||
]);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta: number) => {
|
||||
if (delta < 0) {
|
||||
setIsStickingToBottom(false);
|
||||
}
|
||||
const currentScrollTop = getScrollTop();
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const actualCurrent = Math.min(currentScrollTop, maxScroll);
|
||||
let newScrollTop = Math.max(0, actualCurrent + delta);
|
||||
if (newScrollTop >= maxScroll) {
|
||||
setIsStickingToBottom(true);
|
||||
newScrollTop = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll)),
|
||||
);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
const effectiveTotalHeight = totalHeight - culledHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
effectiveTotalHeight - scrollableContainerHeight,
|
||||
);
|
||||
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(0, offset + culledHeight);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
scrollToEnd: () => {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
},
|
||||
scrollToIndex: ({
|
||||
index,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
index: number;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const offset = index * itemHeight;
|
||||
if (index >= 0 && index < data.length) {
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
scrollToItem: ({
|
||||
item,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const index = data.indexOf(item);
|
||||
if (index !== -1) {
|
||||
const offset = index * itemHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => {
|
||||
const effectiveTotalHeight = totalHeight - culledHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
effectiveTotalHeight - scrollableContainerHeight,
|
||||
);
|
||||
return {
|
||||
scrollTop: Math.min(
|
||||
Math.max(0, getScrollTop() - culledHeight),
|
||||
maxScroll,
|
||||
),
|
||||
scrollHeight: effectiveTotalHeight,
|
||||
innerHeight: scrollableContainerHeight,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[
|
||||
scrollAnchor,
|
||||
totalHeight,
|
||||
getAnchorForScrollTop,
|
||||
data,
|
||||
scrollableContainerHeight,
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
itemHeight,
|
||||
culledHeight,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={
|
||||
isStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Math.max(0, getScrollTop() - culledHeight)
|
||||
}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={Math.max(0, bottomSpacerHeight)} flexShrink={0} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const FixedVirtualizedListWithForwardRef = forwardRef(FixedVirtualizedList) as <
|
||||
T,
|
||||
>(
|
||||
props: FixedVirtualizedListProps<T> & {
|
||||
ref?: React.Ref<FixedVirtualizedListRef<T>>;
|
||||
},
|
||||
) => React.ReactElement;
|
||||
|
||||
export { FixedVirtualizedListWithForwardRef as FixedVirtualizedList };
|
||||
|
||||
FixedVirtualizedList.displayName = 'FixedVirtualizedList';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useId,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
@@ -22,9 +23,11 @@ import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
interface ScrollableProps {
|
||||
children?: React.ReactNode;
|
||||
itemKey?: string;
|
||||
width?: number;
|
||||
height?: number | string;
|
||||
maxWidth?: number;
|
||||
@@ -40,6 +43,7 @@ interface ScrollableProps {
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
children,
|
||||
itemKey,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
@@ -53,7 +57,16 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
stableScrollback,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const state = virtualizedListContext.getItemState(itemKey, 'scrollTop');
|
||||
return typeof state === 'number' ? state : 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
const overflowActions = useOverflowActions();
|
||||
@@ -73,6 +86,19 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
virtualizedListContext.setItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
scrollTopRef.current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportOverflow && size.scrollHeight > size.innerHeight) {
|
||||
overflowActions?.addOverflowingId?.(id);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
@@ -25,17 +27,18 @@ import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
itemKey?: string;
|
||||
hasFocus: boolean;
|
||||
width?: string | number;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
containerHeight?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
@@ -48,10 +51,44 @@ function ScrollableList<T>(
|
||||
ref: React.Ref<ScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const {
|
||||
hasFocus,
|
||||
width,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
itemKey,
|
||||
} = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const restoredTop = virtualizedListContext.getItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
);
|
||||
if (typeof restoredTop === 'number') {
|
||||
virtualizedListRef.current?.scrollTo(restoredTop);
|
||||
}
|
||||
}
|
||||
}, [itemKey, virtualizedListContext]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const top = virtualizedListRef.current?.getScrollState().scrollTop;
|
||||
if (top !== undefined) {
|
||||
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -265,6 +302,7 @@ function ScrollableList<T>(
|
||||
scrollbar={scrollbar}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
stableScrollback={stableScrollback}
|
||||
maxScrollbackLength={maxScrollbackLength}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import type { VirtualizedListRef } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRef } from 'react';
|
||||
|
||||
describe('<VirtualizedList /> backbuffer regression', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
it('provides a sufficient history buffer regardless of height estimation', async () => {
|
||||
// 1000 items, each 1 line high.
|
||||
const data = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `Item ${String(i).padStart(3, '0')}`,
|
||||
);
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
|
||||
const { waitUntilReady, unmount } = await render(
|
||||
<Box height={50} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 10}
|
||||
initialScrollIndex={999}
|
||||
overflowToBackbuffer={true}
|
||||
renderStatic={true}
|
||||
maxScrollbackLength={150}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
try {
|
||||
const state = ref.current?.getScrollState();
|
||||
// Viewport is 50, backbuffer is 150.
|
||||
// Total scrollHeight should be AT LEAST 200 lines.
|
||||
// Since our fix is item-based, and items are 1 line high, it should be
|
||||
// exactly or very close to 200.
|
||||
expect(state?.scrollHeight).toBeGreaterThanOrEqual(200);
|
||||
expect(state?.innerHeight).toBe(50);
|
||||
} finally {
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<VirtualizedList /> fallback', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
it('uses default maxScrollbackLength of 1000 when not provided', async () => {
|
||||
const longData = Array.from({ length: 2000 }, (_, i) => `Item ${i}`);
|
||||
const renderedIndices = new Set<number>();
|
||||
const renderItem1px = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: string;
|
||||
index: number;
|
||||
}) => {
|
||||
renderedIndices.add(index);
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={1999}
|
||||
overflowToBackbuffer={true}
|
||||
// maxScrollbackLength is NOT provided
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// Viewport height is 10.
|
||||
// initialScrollIndex is 1999.
|
||||
// actualScrollTop = 2000 - 10 = 1990.
|
||||
// Default fallback maxScrollbackLength = 1000.
|
||||
// targetOffset = 1990 - 1000 = 990.
|
||||
// renderRangeStart should be around 989/990.
|
||||
// Items below 980 should NOT be rendered.
|
||||
// Items around 1000 SHOULD be rendered.
|
||||
|
||||
// Check viewport items are rendered
|
||||
expect(renderedIndices.has(1995)).toBe(true);
|
||||
expect(renderedIndices.has(1999)).toBe(true);
|
||||
|
||||
// Check items in maxScrollbackLength (1000) are rendered
|
||||
expect(renderedIndices.has(1000)).toBe(true);
|
||||
expect(renderedIndices.has(1100)).toBe(true);
|
||||
|
||||
// Check items beyond maxScrollbackLength are NOT rendered
|
||||
expect(renderedIndices.has(0)).toBe(false);
|
||||
expect(renderedIndices.has(500)).toBe(false);
|
||||
expect(renderedIndices.has(900)).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
||||
import {
|
||||
SCROLL_TO_ITEM_END,
|
||||
VirtualizedList,
|
||||
type VirtualizedListRef,
|
||||
} from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import {
|
||||
createRef,
|
||||
@@ -115,6 +119,41 @@ describe('<VirtualizedList />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('rerenders cached items when renderItem changes', async () => {
|
||||
const data = ['Item 0'];
|
||||
const renderWithLabel = (label: string) => (
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>
|
||||
{label} {item}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
|
||||
renderWithLabel('Initial'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Initial Item 0');
|
||||
|
||||
await act(async () => {
|
||||
rerender(renderWithLabel('Updated'));
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Updated Item 0');
|
||||
expect(lastFrame()).not.toContain('Initial Item 0');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('scrolls down to show new items when requested via ref', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { lastFrame, waitUntilReady, unmount } = await render(
|
||||
@@ -170,7 +209,7 @@ describe('<VirtualizedList />', () => {
|
||||
(_, i) => `Item ${i}`,
|
||||
);
|
||||
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<Box height={20} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={veryLongData}
|
||||
@@ -184,8 +223,12 @@ describe('<VirtualizedList />', () => {
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
},
|
||||
@@ -316,12 +359,69 @@ describe('<VirtualizedList />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
it('culls items that exceed maxScrollbackLength when overflowToBackbuffer is true', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame, unmount } = await render(
|
||||
const renderedIndices = new Set<number>();
|
||||
const renderItem1px = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: string;
|
||||
index: number;
|
||||
}) => {
|
||||
renderedIndices.add(index);
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = await render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// Viewport height is 10, total items = 100.
|
||||
// actualScrollTop = 92 (due to top/bottom borders taking 2 lines out of 10, inner height 8).
|
||||
// wait, if height is 10 with round border, inner height is 8.
|
||||
// actualScrollTop = 100 - 8 = 92.
|
||||
// maxScrollbackLength = 10.
|
||||
// targetOffset = 92 - 10 = 82.
|
||||
// So renderRangeStart should be 81 (or 82).
|
||||
// Items 0 to 80 should not be rendered!
|
||||
|
||||
// Check viewport items are rendered
|
||||
expect(renderedIndices.has(95)).toBe(true);
|
||||
expect(renderedIndices.has(99)).toBe(true);
|
||||
|
||||
// Check items in maxScrollbackLength are rendered
|
||||
expect(renderedIndices.has(85)).toBe(true);
|
||||
|
||||
// Check items beyond maxScrollbackLength are NOT rendered
|
||||
expect(renderedIndices.has(0)).toBe(false);
|
||||
expect(renderedIndices.has(50)).toBe(false);
|
||||
expect(renderedIndices.has(75)).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('crops the document height when maxScrollbackLength is exceeded', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
@@ -330,18 +430,243 @@ describe('<VirtualizedList />', () => {
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={50}
|
||||
copyModeEnabled={true}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// Item 50 should be visible
|
||||
expect(lastFrame()).toContain('Item 50');
|
||||
// And surrounding items
|
||||
expect(lastFrame()).toContain('Item 59');
|
||||
// But far away items should not be (ensures we are actually scrolled)
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
await waitUntilReady();
|
||||
|
||||
// Viewport height is 10.
|
||||
// maxScrollbackLength = 10.
|
||||
// Total expected scrollHeight = 10 + 10 = 20.
|
||||
const state = ref.current?.getScrollState();
|
||||
expect(state?.scrollHeight).toBe(20);
|
||||
|
||||
// The top of the projected document (offset 0) should correspond to absolute offset 80.
|
||||
// getAnchorForScrollTop(80) will return index 90 because it's near the bottom and uses a bottom anchor.
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(0);
|
||||
});
|
||||
expect(ref.current?.getScrollIndex()).toBe(90);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('culls the backbuffer by measured row height instead of item count', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const renderedIndices = new Set<number>();
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={({ item, index }) => {
|
||||
renderedIndices.add(index);
|
||||
return (
|
||||
<Box height={2}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 2}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const state = ref.current?.getScrollState();
|
||||
expect(state?.scrollHeight).toBe(20);
|
||||
expect(state?.innerHeight).toBe(10);
|
||||
expect(renderedIndices.has(90)).toBe(true);
|
||||
expect(renderedIndices.has(85)).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps keyboard scrolling in logical history coordinates after culling', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(10);
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollBy(-1);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const state = ref.current?.getScrollState();
|
||||
expect(state?.scrollTop).toBeGreaterThan(0);
|
||||
expect(lastFrame()).not.toContain('Item 79');
|
||||
expect(lastFrame()).not.toContain('Item 80');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('measures mounted zero-height items instead of keeping their estimate', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const data = ['Item 0', 'Item 1', 'pending'];
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
<Box height={50} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={data}
|
||||
renderItem={({ item }) =>
|
||||
item === 'pending' ? (
|
||||
<Box height={0} />
|
||||
) : (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 10}
|
||||
initialScrollIndex={2}
|
||||
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
expect(ref.current?.getScrollState()).toEqual({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 2,
|
||||
innerHeight: 50,
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not forget item heights when items are prepended', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const data = ['Item 1', 'Item 2'];
|
||||
const { rerender, waitUntilReady, unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
// Item 1 and 2 measured. totalHeight = 2.
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(2);
|
||||
});
|
||||
|
||||
// Prepend Item 0
|
||||
const newData = ['Item 0', 'Item 1', 'Item 2'];
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={newData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
// With the Map-based cache, Item 1 and 2 heights (1 each) should be preserved
|
||||
// even though their indices changed.
|
||||
// Item 0 is new and uses estimate 1000.
|
||||
// So totalHeight should be 1002 (before Item 0 is measured).
|
||||
// Note: It might already be 3 if Item 0 was measured immediately, but it
|
||||
// definitely shouldn't be 3000 (which it would be if Item 1 and 2 were forgotten).
|
||||
const scrollHeight = ref.current?.getScrollState().scrollHeight;
|
||||
expect(scrollHeight).toBeGreaterThan(0);
|
||||
expect(scrollHeight).toBeLessThan(3000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(3);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('updates totalHeight correctly when estimated height differs from real height and scrolled up', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const longData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
|
||||
const itemHeight = 1;
|
||||
const renderItem1px = ({ item }: { item: string }) => (
|
||||
<Box height={itemHeight}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
<Box height={5} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(i * 1000);
|
||||
});
|
||||
await waitUntilReady();
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(0);
|
||||
});
|
||||
// Wait for the final scroll top to settle and height to be correct
|
||||
await waitFor(() => {
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(0);
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(10);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useState } from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('VirtualizedList Interactivity', () => {
|
||||
const keyExtractor = (item: { id: string }) => item.id;
|
||||
|
||||
const InteractiveItem = ({
|
||||
id,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const { ref } = useVirtualizedListClick(id, 'toggle', onToggle);
|
||||
return (
|
||||
<Box height={1} width={80} ref={ref}>
|
||||
<Text>Item {id}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
it('triggers callback when tagged area is clicked', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const data = [{ id: '1' }];
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={10} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={({ item }) => (
|
||||
<InteractiveItem id={item.id} onToggle={onToggle} />
|
||||
)}
|
||||
/>
|
||||
</Box>,
|
||||
{ mouseEventsEnabled: true },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
// Simulate click on the first line (Item 1)
|
||||
// VirtualizedList is at (0,0) and Item 1 is at (0,0) relative to list.
|
||||
// simulateClick expects absolute coordinates.
|
||||
// In renderWithProviders, the wrapper Box is at (0,0)?
|
||||
// Actually getBoundingBox(state.current.container) in VirtualizedList will give absolute coords.
|
||||
await simulateClick(1, 1);
|
||||
|
||||
await waitFor(() => expect(onToggle).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('wakes up static item and triggers callback on click', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const data = [{ id: '1' }];
|
||||
|
||||
const TestComponent = () => {
|
||||
const [isStatic, setIsStatic] = useState(false);
|
||||
return (
|
||||
<Box height={10} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={({ item }) => (
|
||||
<InteractiveItem id={item.id} onToggle={onToggle} />
|
||||
)}
|
||||
isStaticItem={() => isStatic}
|
||||
/>
|
||||
<Box
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
setTimeout(() => setIsStatic(true), 100);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(<TestComponent />, {
|
||||
mouseEventsEnabled: true,
|
||||
});
|
||||
|
||||
await waitUntilReady();
|
||||
// Wait for the transition to static to happen and be recorded
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
// Click to wake up and trigger
|
||||
await simulateClick(1, 1);
|
||||
|
||||
await waitFor(() => expect(onToggle).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -7,41 +7,41 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 1 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 2 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 3 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 500 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ▄│
|
||||
│ ▀│
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 501 │
|
||||
│ │
|
||||
│ │
|
||||
│ ▄│
|
||||
│ ▀│
|
||||
│Item 502 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 503 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -53,7 +53,7 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 997 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
@@ -35,6 +36,7 @@ const MAX_MOUSE_BUFFER_SIZE = 4096;
|
||||
interface MouseContextValue {
|
||||
subscribe: (handler: MouseHandler) => void;
|
||||
unsubscribe: (handler: MouseHandler) => void;
|
||||
broadcast: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const MouseContext = createContext<MouseContextValue | undefined>(undefined);
|
||||
@@ -50,7 +52,7 @@ export function useMouseContext() {
|
||||
export function useMouse(handler: MouseHandler, { isActive = true } = {}) {
|
||||
const { subscribe, unsubscribe } = useMouseContext();
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
@@ -92,14 +94,8 @@ export function MouseProvider({
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mouseEventsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mouseBuffer = '';
|
||||
|
||||
const broadcast = (event: MouseEvent) => {
|
||||
const broadcast = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
let handled = false;
|
||||
for (const handler of subscribers) {
|
||||
if (handler(event) === true) {
|
||||
@@ -143,7 +139,16 @@ export function MouseProvider({
|
||||
// events not the terminal.
|
||||
appEvents.emit(AppEvent.SelectionWarning);
|
||||
}
|
||||
};
|
||||
},
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mouseEventsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mouseBuffer = '';
|
||||
|
||||
const handleData = (data: Buffer | string) => {
|
||||
mouseBuffer += typeof data === 'string' ? data : data.toString('utf-8');
|
||||
@@ -190,11 +195,11 @@ export function MouseProvider({
|
||||
return () => {
|
||||
stdin.removeListener('data', handleData);
|
||||
};
|
||||
}, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]);
|
||||
}, [stdin, mouseEventsEnabled, broadcast, debugKeystrokeLogging]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ subscribe, unsubscribe }),
|
||||
[subscribe, unsubscribe],
|
||||
() => ({ subscribe, unsubscribe, broadcast }),
|
||||
[subscribe, unsubscribe, broadcast],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,7 @@ export const useMouseClick = (
|
||||
(event: MouseEvent) => {
|
||||
const eventName =
|
||||
name ?? (button === 'left' ? 'left-press' : 'right-release');
|
||||
|
||||
if (event.name === eventName && containerRef.current) {
|
||||
const { x, y, width, height } = getBoundingBox(containerRef.current);
|
||||
// Terminal mouse events are 1-based, Ink layout is 0-based.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useContext, useLayoutEffect, useCallback, useRef } from 'react';
|
||||
import { VirtualizedListContext } from '../components/shared/VirtualizedList.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
|
||||
/**
|
||||
* A hook to register a clickable area within a VirtualizedList item.
|
||||
* This works seamlessly with both static and dynamic rendering.
|
||||
*
|
||||
* @param itemKey The unique key for the list item.
|
||||
* @param areaId A unique identifier for this clickable area within the list item.
|
||||
* @param callback The function to execute when the area is clicked.
|
||||
* @param options Configuration options.
|
||||
* @returns Props to spread onto the clickable component.
|
||||
*/
|
||||
export const useVirtualizedListClick = (
|
||||
itemKey: string | undefined,
|
||||
areaId: string,
|
||||
callback: () => void,
|
||||
options: { isActive?: boolean } = {},
|
||||
) => {
|
||||
const { isActive = true } = options;
|
||||
const context = useContext(VirtualizedListContext);
|
||||
const elementRef = useRef<DOMElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive && context && itemKey) {
|
||||
context.registerClickCallback(itemKey, areaId, callback);
|
||||
return () => {
|
||||
context.unregisterClickCallback(itemKey, areaId);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [isActive, context, itemKey, areaId, callback]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive || !context || !elementRef.current) return;
|
||||
context.registerClickableArea(elementRef.current, areaId);
|
||||
return () => {
|
||||
if (elementRef.current) {
|
||||
context.unregisterClickableArea(elementRef.current);
|
||||
}
|
||||
};
|
||||
}, [isActive, context, areaId]);
|
||||
|
||||
const ref = useCallback(
|
||||
(el: DOMElement | null) => {
|
||||
if (elementRef.current && context) {
|
||||
context.unregisterClickableArea(elementRef.current);
|
||||
}
|
||||
elementRef.current = el;
|
||||
if (el && context && isActive) {
|
||||
context.registerClickableArea(el, areaId);
|
||||
}
|
||||
},
|
||||
[isActive, context, areaId],
|
||||
);
|
||||
|
||||
return { ref };
|
||||
};
|
||||
@@ -23,8 +23,8 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width="90%"
|
||||
height="100%"
|
||||
width={uiState.terminalWidth}
|
||||
height={uiState.terminalHeight}
|
||||
ref={uiState.rootUiRef}
|
||||
>
|
||||
<Notifications />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface MarkdownDisplayProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="360" viewBox="0 0 920 360">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<rect width="920" height="513" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
@@ -30,16 +30,16 @@
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="274" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="0" y="410" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="427" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="461" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="360" viewBox="0 0 920 360">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<rect width="920" height="513" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
@@ -30,16 +30,16 @@
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="257" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="274" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="855" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
|
||||
<text x="855" y="308" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="0" y="410" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="427" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="427" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="427" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="855" y="427" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="444" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
|
||||
<text x="855" y="461" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="360" viewBox="0 0 920 360">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<rect width="920" height="513" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
@@ -30,16 +30,16 @@
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="274" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="0" y="410" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="427" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="461" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -16,6 +16,15 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
@@ -39,6 +48,15 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ run_shell_command │
|
||||
│ │
|
||||
@@ -62,6 +80,15 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('PolicyEngine - Core Tools Mapping', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should allow tools explicitly listed in settings.tools.core', async () => {
|
||||
it('should map tools listed in settings.tools.core to ALLOW with correct priority and fallback to default policies', async () => {
|
||||
const settings = {
|
||||
tools: {
|
||||
core: ['run_shell_command(ls)', 'run_shell_command(git status)'],
|
||||
@@ -63,7 +63,7 @@ describe('PolicyEngine - Core Tools Mapping', () => {
|
||||
expect(result3.decision).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow tools in tools.core even if they are restricted by default policies', async () => {
|
||||
it('should map tools in tools.core with higher priority than default policies', async () => {
|
||||
// By default run_shell_command is ASK_USER.
|
||||
// Putting it in tools.core should make it ALLOW.
|
||||
const settings = {
|
||||
|
||||
@@ -537,6 +537,13 @@
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxScrollbackLength": {
|
||||
"title": "Max Scrollback Length",
|
||||
"description": "Maximum number of lines to keep in the terminal scrollback buffer.",
|
||||
"markdownDescription": "Maximum number of lines to keep in the terminal scrollback buffer.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `1000`",
|
||||
"default": 1000,
|
||||
"type": "number"
|
||||
},
|
||||
"showSpinner": {
|
||||
"title": "Show Spinner",
|
||||
"description": "Show the spinner during operations.",
|
||||
|
||||
@@ -10,25 +10,40 @@
|
||||
* @fileoverview CLI entry point for the eval inventory command.
|
||||
*
|
||||
* Scans all eval source files, runs the static analyzer on each,
|
||||
* and prints a human-readable inventory report grouped by policy,
|
||||
* file, and suite.
|
||||
* and prints an inventory report grouped by policy, file, and suite.
|
||||
*
|
||||
* Usage:
|
||||
* npm run eval:inventory
|
||||
* npm run eval:inventory -- --json
|
||||
* npm run eval:inventory -- --root /path/to/repo
|
||||
* npm run eval:inventory -- --root /path/to/repo --json
|
||||
*/
|
||||
|
||||
import {
|
||||
collectInventory,
|
||||
formatInventoryJson,
|
||||
formatInventoryReport,
|
||||
} from './utils/eval-inventory.js';
|
||||
|
||||
async function main() {
|
||||
const rootFlagIndex = process.argv.indexOf('--root');
|
||||
const repoRoot =
|
||||
rootFlagIndex !== -1 && process.argv[rootFlagIndex + 1]
|
||||
? process.argv[rootFlagIndex + 1]
|
||||
: process.cwd();
|
||||
const rootFlagValue =
|
||||
rootFlagIndex !== -1 ? process.argv[rootFlagIndex + 1] : undefined;
|
||||
if (rootFlagIndex !== -1 && rootFlagValue === undefined) {
|
||||
console.error(
|
||||
'Error: --root requires a directory path argument but none was provided.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (rootFlagValue && rootFlagValue.startsWith('--')) {
|
||||
console.error(
|
||||
`Error: --root value "${rootFlagValue}" looks like a flag. Provide a valid directory path.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const repoRoot = rootFlagValue ?? process.cwd();
|
||||
|
||||
const jsonMode = process.argv.includes('--json');
|
||||
|
||||
const result = await collectInventory(repoRoot);
|
||||
|
||||
@@ -37,7 +52,9 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(formatInventoryReport(result));
|
||||
console.log(
|
||||
jsonMode ? formatInventoryJson(result) : formatInventoryReport(result),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
collectInventory,
|
||||
formatInventoryJson,
|
||||
formatInventoryReport,
|
||||
type InventoryJsonOutput,
|
||||
type InventoryResult,
|
||||
} from '../utils/eval-inventory.js';
|
||||
import type { EvalCaseRecord } from '../utils/eval-analysis.js';
|
||||
@@ -30,6 +32,19 @@ function makeCaseRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmptyResult(repoRoot = '/repo'): InventoryResult {
|
||||
return {
|
||||
totalFiles: 0,
|
||||
totalCases: 0,
|
||||
repoRoot,
|
||||
files: [],
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
|
||||
|
||||
describe('eval-inventory', () => {
|
||||
describe('collectInventory', () => {
|
||||
it('discovers eval files from the real evals directory', async () => {
|
||||
@@ -40,6 +55,7 @@ describe('eval-inventory', () => {
|
||||
expect(result.totalCases).toBeGreaterThanOrEqual(90);
|
||||
expect(result.files.length).toBe(result.totalFiles);
|
||||
expect(result.cases.length).toBe(result.totalCases);
|
||||
expect(result.repoRoot).toBe(repoRoot);
|
||||
|
||||
for (const evalCase of result.cases) {
|
||||
expect(evalCase.name).toBeTruthy();
|
||||
@@ -48,13 +64,20 @@ describe('eval-inventory', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('returns zero counts for a directory with no eval files', async () => {
|
||||
const result = await collectInventory(import.meta.dirname);
|
||||
it('returns zero file counts for an evals directory with no matching files', async () => {
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../../');
|
||||
const result = await collectInventory(repoRoot);
|
||||
|
||||
expect(result.totalFiles).toBe(0);
|
||||
expect(result.totalCases).toBe(0);
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.cases).toEqual([]);
|
||||
expect(result.totalFiles).toBeGreaterThanOrEqual(0);
|
||||
expect(result.files.length).toBe(result.totalFiles);
|
||||
expect(result.cases.length).toBe(result.totalCases);
|
||||
expect(result.repoRoot).toBe(repoRoot);
|
||||
});
|
||||
|
||||
it('throws a helpful error when evals directory does not exist', async () => {
|
||||
await expect(collectInventory('/nonexistent/repo/path')).rejects.toThrow(
|
||||
/evals directory not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +86,7 @@ describe('eval-inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 2,
|
||||
totalCases: 3,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'case-1' }),
|
||||
@@ -77,10 +101,11 @@ describe('eval-inventory', () => {
|
||||
expect(report).toContain('2 files · 3 cases · 0 diagnostics');
|
||||
});
|
||||
|
||||
it('groups cases by policy', () => {
|
||||
it('groups cases by policy in canonical order', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
@@ -102,12 +127,39 @@ describe('eval-inventory', () => {
|
||||
expect(report).toContain('USUALLY_PASSES (1 cases)');
|
||||
expect(report).toContain('• stable test');
|
||||
expect(report).toContain('• flaky test');
|
||||
expect(report.indexOf('ALWAYS_PASSES')).toBeLessThan(
|
||||
report.indexOf('USUALLY_PASSES'),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cases with policies not listed in POLICY_ORDER', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'known policy' }),
|
||||
makeCaseRecord({
|
||||
policy: 'FUTURE_POLICY' as never,
|
||||
name: 'future policy',
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).toContain('ALWAYS_PASSES (1 cases)');
|
||||
expect(report).toContain('FUTURE_POLICY (1 cases)');
|
||||
expect(report).toContain('• future policy');
|
||||
});
|
||||
|
||||
it('groups cases by suite name', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ suiteName: 'default', name: 'suite-test' }),
|
||||
@@ -127,7 +179,16 @@ describe('eval-inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 0,
|
||||
files: [],
|
||||
repoRoot: '/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/bad.eval.ts',
|
||||
relativePath: 'evals/bad.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [],
|
||||
diagnostics: [
|
||||
{
|
||||
@@ -144,7 +205,7 @@ describe('eval-inventory', () => {
|
||||
expect(report).toContain('Diagnostics');
|
||||
expect(report).toContain('1 diagnostics');
|
||||
expect(report).toContain(
|
||||
'⚠ /repo/evals/bad.eval.ts:5:3 — Could not resolve policy',
|
||||
'⚠ evals/bad.eval.ts:5:3 — Could not resolve policy',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -152,6 +213,7 @@ describe('eval-inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [makeCaseRecord()],
|
||||
diagnostics: [],
|
||||
@@ -167,6 +229,7 @@ describe('eval-inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
@@ -182,4 +245,466 @@ describe('eval-inventory', () => {
|
||||
expect(report).toContain('• custom test [customHelper]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInventoryJson', () => {
|
||||
it('snapshot: minimal inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'basic eval',
|
||||
policy: 'ALWAYS_PASSES',
|
||||
suiteName: 'core',
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 1,
|
||||
"totalCases": 1,
|
||||
"totalDiagnostics": 0,
|
||||
"byPolicy": {
|
||||
"ALWAYS_PASSES": 1
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"name": "basic eval",
|
||||
"filePath": "evals/test.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "ALWAYS_PASSES",
|
||||
"suiteName": "core",
|
||||
"suiteType": null,
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": []
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('snapshot: mixed policies with diagnostics', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 2,
|
||||
totalCases: 3,
|
||||
repoRoot: '/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/c.eval.ts',
|
||||
relativePath: 'evals/c.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'stable test',
|
||||
policy: 'ALWAYS_PASSES',
|
||||
relativePath: 'evals/a.eval.ts',
|
||||
}),
|
||||
makeCaseRecord({
|
||||
name: 'flaky test',
|
||||
policy: 'USUALLY_PASSES',
|
||||
suiteName: 'tools',
|
||||
suiteType: 'behavioral',
|
||||
relativePath: 'evals/b.eval.ts',
|
||||
}),
|
||||
makeCaseRecord({
|
||||
name: 'failing test',
|
||||
policy: 'USUALLY_FAILS',
|
||||
timeout: 30000,
|
||||
hasFiles: true,
|
||||
relativePath: 'evals/b.eval.ts',
|
||||
}),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'Could not resolve policy',
|
||||
filePath: '/repo/evals/c.eval.ts',
|
||||
location: { line: 10, column: 5 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 2,
|
||||
"totalCases": 3,
|
||||
"totalDiagnostics": 1,
|
||||
"byPolicy": {
|
||||
"ALWAYS_PASSES": 1,
|
||||
"USUALLY_PASSES": 1,
|
||||
"USUALLY_FAILS": 1
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"name": "stable test",
|
||||
"filePath": "evals/a.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "ALWAYS_PASSES",
|
||||
"suiteName": null,
|
||||
"suiteType": null,
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "flaky test",
|
||||
"filePath": "evals/b.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "USUALLY_PASSES",
|
||||
"suiteName": "tools",
|
||||
"suiteType": "behavioral",
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "failing test",
|
||||
"filePath": "evals/b.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "USUALLY_FAILS",
|
||||
"suiteName": null,
|
||||
"suiteType": null,
|
||||
"timeout": 30000,
|
||||
"hasFiles": true,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"message": "Could not resolve policy",
|
||||
"filePath": "evals/c.eval.ts",
|
||||
"location": {
|
||||
"line": 10,
|
||||
"column": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('snapshot: empty inventory', () => {
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 0,
|
||||
"totalCases": 0,
|
||||
"totalDiagnostics": 0,
|
||||
"byPolicy": {}
|
||||
},
|
||||
"cases": [],
|
||||
"diagnostics": []
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('produces valid JSON with version field', () => {
|
||||
const result: InventoryResult = {
|
||||
...makeEmptyResult(),
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
cases: [makeCaseRecord()],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
|
||||
expect(parsed.version).toBe(1);
|
||||
});
|
||||
|
||||
it('includes correct summary counts', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 3,
|
||||
totalCases: 4,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'USUALLY_PASSES' }),
|
||||
makeCaseRecord({ policy: 'USUALLY_FAILS' }),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'test',
|
||||
filePath: 'test.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
|
||||
expect(parsed.summary).toEqual({
|
||||
totalFiles: 3,
|
||||
totalCases: 4,
|
||||
totalDiagnostics: 1,
|
||||
byPolicy: {
|
||||
ALWAYS_PASSES: 2,
|
||||
USUALLY_PASSES: 1,
|
||||
USUALLY_FAILS: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps case fields correctly with nulls for missing optionals', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'detailed case',
|
||||
relativePath: 'evals/detail.eval.ts',
|
||||
helperName: 'appEvalTest',
|
||||
baseHelperName: 'appEvalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
hasFiles: true,
|
||||
hasPrompt: true,
|
||||
location: { line: 42, column: 3 },
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
const firstCase = parsed.cases[0];
|
||||
|
||||
expect(firstCase).toEqual({
|
||||
name: 'detailed case',
|
||||
filePath: 'evals/detail.eval.ts',
|
||||
helperName: 'appEvalTest',
|
||||
baseHelperName: 'appEvalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
suiteName: null,
|
||||
suiteType: null,
|
||||
timeout: null,
|
||||
hasFiles: true,
|
||||
hasPrompt: true,
|
||||
location: { line: 42, column: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses relative paths not absolute paths', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/absolute/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
relativePath: 'evals/test.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
relativePath: 'evals/test.eval.ts',
|
||||
}),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'test diagnostic',
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).not.toContain('/absolute/repo');
|
||||
expect(json).toContain('evals/test.eval.ts');
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
expect(parsed.diagnostics[0].filePath).toBe('evals/test.eval.ts');
|
||||
});
|
||||
|
||||
it('relativizes absolute diagnostic path not in file lookup using repoRoot', () => {
|
||||
const repoRoot = '/repo';
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 0,
|
||||
repoRoot,
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/known.eval.ts',
|
||||
relativePath: 'evals/known.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'cross-file diagnostic',
|
||||
filePath: '/repo/evals/other.eval.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
|
||||
expect(parsed.diagnostics[0].filePath).toBe('evals/other.eval.ts');
|
||||
expect(parsed.diagnostics[0].filePath).not.toMatch(/^\//);
|
||||
});
|
||||
|
||||
it('includes policies not listed in POLICY_ORDER in byPolicy', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'unknown' }),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
|
||||
expect(parsed.summary.byPolicy).toEqual({
|
||||
ALWAYS_PASSES: 1,
|
||||
unknown: 1,
|
||||
});
|
||||
|
||||
const sum = Object.values(parsed.summary.byPolicy).reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
expect(sum).toBe(parsed.summary.totalCases);
|
||||
});
|
||||
|
||||
it('emits deterministic output', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ name: 'a', policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ name: 'b', policy: 'USUALLY_PASSES' }),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const first = formatInventoryJson(result, FIXED_NOW);
|
||||
const second = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('generated field is valid ISO-8601', () => {
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
|
||||
const date = new Date(parsed.generated);
|
||||
expect(date.getTime()).not.toBeNaN();
|
||||
expect(parsed.generated).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/,
|
||||
);
|
||||
});
|
||||
|
||||
describe('environment overrides for timestamp', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('uses SOURCE_DATE_EPOCH if set', () => {
|
||||
vi.stubEnv('SOURCE_DATE_EPOCH', '1700000000');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('2023-11-14T22:13:20.000Z');
|
||||
});
|
||||
|
||||
it('uses epoch 0 if EVAL_INVENTORY_STABLE_DATE is set', () => {
|
||||
vi.stubEnv('EVAL_INVENTORY_STABLE_DATE', '1');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('1970-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('uses epoch 0 if EVAL_INVENTORY_DETERMINISTIC is set', () => {
|
||||
vi.stubEnv('EVAL_INVENTORY_DETERMINISTIC', 'true');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('1970-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,9 +16,17 @@ import {
|
||||
type EvalPolicy,
|
||||
} from './eval-analysis.js';
|
||||
|
||||
const POLICY_ORDER: EvalPolicy[] = [
|
||||
'ALWAYS_PASSES',
|
||||
'USUALLY_PASSES',
|
||||
'USUALLY_FAILS',
|
||||
'unknown',
|
||||
];
|
||||
|
||||
export interface InventoryResult {
|
||||
totalFiles: number;
|
||||
totalCases: number;
|
||||
repoRoot: string;
|
||||
files: EvalFileAnalysis[];
|
||||
cases: readonly EvalCaseRecord[];
|
||||
diagnostics: readonly EvalAnalysisDiagnostic[];
|
||||
@@ -32,6 +40,22 @@ export async function collectInventory(
|
||||
repoRoot: string,
|
||||
): Promise<InventoryResult> {
|
||||
const evalsDir = path.join(repoRoot, 'evals');
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(evalsDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`evals path exists but is not a directory: ${evalsDir}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`evals directory not found under repo root: ${evalsDir}\n` +
|
||||
`Make sure --root points to the repository root.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pattern = '**/*.eval.{ts,tsx}';
|
||||
|
||||
const evalFiles = await glob(pattern, {
|
||||
@@ -57,6 +81,7 @@ export async function collectInventory(
|
||||
return {
|
||||
totalFiles: files.length,
|
||||
totalCases: allCases.length,
|
||||
repoRoot,
|
||||
files,
|
||||
cases: allCases,
|
||||
diagnostics: allDiagnostics,
|
||||
@@ -81,20 +106,30 @@ export function formatInventoryReport(result: InventoryResult): string {
|
||||
lines.push('By Policy');
|
||||
lines.push('─────────');
|
||||
|
||||
const byPolicy = groupBy(result.cases, (c) => c.policy);
|
||||
const policyOrder: EvalPolicy[] = [
|
||||
'ALWAYS_PASSES',
|
||||
'USUALLY_PASSES',
|
||||
'USUALLY_FAILS',
|
||||
'unknown',
|
||||
];
|
||||
const byPolicyMap = groupBy(result.cases, (c) => c.policy);
|
||||
|
||||
for (const policy of policyOrder) {
|
||||
const cases = byPolicy.get(policy);
|
||||
const renderedPolicies = new Set<string>();
|
||||
for (const policy of POLICY_ORDER) {
|
||||
const cases = byPolicyMap.get(policy);
|
||||
if (!cases || cases.length === 0) {
|
||||
continue;
|
||||
}
|
||||
renderedPolicies.add(policy);
|
||||
lines.push(`${policy} (${cases.length} cases)`);
|
||||
|
||||
const byFile = groupBy(cases, (c) => c.relativePath);
|
||||
for (const [filePath, fileCases] of byFile) {
|
||||
lines.push(` ${filePath}`);
|
||||
for (const evalCase of fileCases) {
|
||||
lines.push(` • ${evalCase.name} [${evalCase.helperName}]`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
for (const [policy, cases] of byPolicyMap) {
|
||||
if (renderedPolicies.has(policy) || !cases || cases.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`${policy} (${cases.length} cases)`);
|
||||
|
||||
const byFile = groupBy(cases, (c) => c.relativePath);
|
||||
@@ -141,10 +176,11 @@ export function formatInventoryReport(result: InventoryResult): string {
|
||||
lines.push('Diagnostics');
|
||||
lines.push('───────────');
|
||||
for (const diagnostic of result.diagnostics) {
|
||||
const displayPath =
|
||||
diagnostic.filePath === '<inline>'
|
||||
? diagnostic.filePath
|
||||
: (filePaths.get(diagnostic.filePath) ?? diagnostic.filePath);
|
||||
const displayPath = resolveRelativePath(
|
||||
diagnostic.filePath,
|
||||
filePaths,
|
||||
result.repoRoot,
|
||||
);
|
||||
lines.push(
|
||||
`⚠ ${displayPath}:${diagnostic.location.line}:${diagnostic.location.column} — ${diagnostic.message}`,
|
||||
);
|
||||
@@ -155,6 +191,128 @@ export function formatInventoryReport(result: InventoryResult): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export interface InventoryJsonOutput {
|
||||
version: 1;
|
||||
generated: string;
|
||||
summary: {
|
||||
totalFiles: number;
|
||||
totalCases: number;
|
||||
totalDiagnostics: number;
|
||||
byPolicy: Record<string, number>;
|
||||
};
|
||||
cases: InventoryJsonCase[];
|
||||
diagnostics: InventoryJsonDiagnostic[];
|
||||
}
|
||||
|
||||
interface InventoryJsonCase {
|
||||
name: string;
|
||||
filePath: string;
|
||||
helperName: string;
|
||||
baseHelperName: string;
|
||||
policy: string;
|
||||
suiteName: string | null;
|
||||
suiteType: string | null;
|
||||
timeout: number | null;
|
||||
hasFiles: boolean;
|
||||
hasPrompt: boolean;
|
||||
location: { line: number; column: number };
|
||||
}
|
||||
|
||||
interface InventoryJsonDiagnostic {
|
||||
severity: string;
|
||||
message: string;
|
||||
filePath: string;
|
||||
location: { line: number; column: number };
|
||||
}
|
||||
|
||||
export function formatInventoryJson(
|
||||
result: InventoryResult,
|
||||
now?: Date,
|
||||
): string {
|
||||
const filePathLookup = new Map<string, string>();
|
||||
for (const f of result.files) {
|
||||
filePathLookup.set(f.filePath, f.relativePath);
|
||||
}
|
||||
|
||||
const policyCounts = new Map<string, number>();
|
||||
for (const evalCase of result.cases) {
|
||||
policyCounts.set(
|
||||
evalCase.policy,
|
||||
(policyCounts.get(evalCase.policy) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const byPolicy: Record<string, number> = {};
|
||||
for (const policy of POLICY_ORDER) {
|
||||
const count = policyCounts.get(policy);
|
||||
if (count !== undefined) {
|
||||
byPolicy[policy] = count;
|
||||
}
|
||||
}
|
||||
for (const [policy, count] of policyCounts) {
|
||||
if (!(policy in byPolicy)) {
|
||||
byPolicy[policy] = count;
|
||||
}
|
||||
}
|
||||
|
||||
let generatedDate = now;
|
||||
if (!generatedDate && process.env.SOURCE_DATE_EPOCH) {
|
||||
const epoch = parseInt(process.env.SOURCE_DATE_EPOCH, 10);
|
||||
if (!isNaN(epoch)) {
|
||||
generatedDate = new Date(epoch * 1000);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!generatedDate &&
|
||||
(process.env.EVAL_INVENTORY_STABLE_DATE ||
|
||||
process.env.EVAL_INVENTORY_DETERMINISTIC)
|
||||
) {
|
||||
generatedDate = new Date(0);
|
||||
}
|
||||
if (!generatedDate) {
|
||||
generatedDate = new Date();
|
||||
}
|
||||
|
||||
const output: InventoryJsonOutput = {
|
||||
version: 1,
|
||||
generated: generatedDate.toISOString(),
|
||||
summary: {
|
||||
totalFiles: result.totalFiles,
|
||||
totalCases: result.totalCases,
|
||||
totalDiagnostics: result.diagnostics.length,
|
||||
byPolicy,
|
||||
},
|
||||
cases: result.cases.map((c) => ({
|
||||
name: c.name,
|
||||
filePath: c.relativePath,
|
||||
helperName: c.helperName,
|
||||
baseHelperName: c.baseHelperName,
|
||||
policy: c.policy,
|
||||
suiteName: c.suiteName ?? null,
|
||||
suiteType: c.suiteType ?? null,
|
||||
timeout: c.timeout ?? null,
|
||||
hasFiles: c.hasFiles,
|
||||
hasPrompt: c.hasPrompt,
|
||||
location: { line: c.location.line, column: c.location.column },
|
||||
})),
|
||||
diagnostics: result.diagnostics.map((d) => {
|
||||
const relativePath = resolveRelativePath(
|
||||
d.filePath,
|
||||
filePathLookup,
|
||||
result.repoRoot,
|
||||
);
|
||||
return {
|
||||
severity: d.severity,
|
||||
message: d.message,
|
||||
filePath: relativePath,
|
||||
location: { line: d.location.line, column: d.location.column },
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return JSON.stringify(output, null, 2);
|
||||
}
|
||||
|
||||
function groupBy<T>(
|
||||
items: readonly T[],
|
||||
keyFn: (item: T) => string,
|
||||
@@ -171,3 +329,24 @@ function groupBy<T>(
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function resolveRelativePath(
|
||||
filePath: string,
|
||||
lookup: Map<string, string>,
|
||||
baseDir: string,
|
||||
): string {
|
||||
if (filePath === '<inline>') {
|
||||
return filePath;
|
||||
}
|
||||
const mapped = lookup.get(filePath);
|
||||
if (mapped !== undefined) {
|
||||
return mapped;
|
||||
}
|
||||
return path.isAbsolute(filePath)
|
||||
? path.relative(baseDir, filePath).replace(/\\/g, '/')
|
||||
: filePath;
|
||||
}
|
||||
|
||||
function isNodeError(err: unknown): err is NodeJS.ErrnoException {
|
||||
return err instanceof Error && 'code' in err;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { DOMElement } from 'ink';
|
||||
|
||||
export const isDOMElement = (node: unknown): node is DOMElement =>
|
||||
Boolean(
|
||||
node &&
|
||||
typeof node === 'object' &&
|
||||
'nodeName' in node &&
|
||||
(node as { nodeName?: unknown }).nodeName !== '#text',
|
||||
);
|
||||