Compare commits

..

4 Commits

Author SHA1 Message Date
Aishanee Shah a8d4d944e8 test: update local-executor tests for relative path preference 2026-02-26 20:42:26 +00:00
Aishanee Shah 358ca8092a revert: documentation changes for path preference 2026-02-26 20:33:17 +00:00
Aishanee Shah 69028570ed docs: prefer relative paths in tool documentation and system prompt 2026-02-26 20:26:08 +00:00
Aishanee Shah f593928cd6 feat(tools): prefer relative paths in tool descriptions and interfaces 2026-02-26 20:24:22 +00:00
205 changed files with 3096 additions and 7098 deletions
+12 -13
View File
@@ -31,7 +31,6 @@ jobs:
name: 'Merge Queue Skipper'
permissions: 'read-all'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
outputs:
skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}'
steps:
@@ -43,7 +42,7 @@ jobs:
download_repo_name:
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')"
if: "${{github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'}}"
outputs:
repo_name: '${{ steps.output-repo-name.outputs.repo_name }}'
head_sha: '${{ steps.output-repo-name.outputs.head_sha }}'
@@ -54,7 +53,7 @@ jobs:
REPO_NAME: '${{ github.event.inputs.repo_name }}'
run: |
mkdir -p ./pr
echo "${REPO_NAME}" > ./pr/repo_name
echo '${REPO_NAME}' > ./pr/repo_name
- uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'repo_name'
@@ -92,7 +91,7 @@ jobs:
name: 'Parse run context'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'download_repo_name'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
outputs:
repository: '${{ steps.set_context.outputs.REPO }}'
sha: '${{ steps.set_context.outputs.SHA }}'
@@ -112,11 +111,11 @@ jobs:
permissions: 'write-all'
needs:
- 'parse_run_context'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
steps:
- name: 'Set pending status'
uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
with:
allowForks: 'true'
repo: '${{ github.repository }}'
@@ -132,7 +131,7 @@ jobs:
- 'parse_run_context'
runs-on: 'gemini-cli-ubuntu-16-core'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
strategy:
fail-fast: false
matrix:
@@ -185,7 +184,7 @@ jobs:
- 'parse_run_context'
runs-on: 'macos-latest'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -223,7 +222,7 @@ jobs:
- 'merge_queue_skipper'
- 'parse_run_context'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
steps:
- name: 'Checkout'
@@ -283,7 +282,7 @@ jobs:
- 'parse_run_context'
runs-on: 'gemini-cli-ubuntu-16-core'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -310,7 +309,7 @@ jobs:
e2e:
name: 'E2E'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
needs:
- 'e2e_linux'
- 'e2e_mac'
@@ -338,14 +337,14 @@ jobs:
set_workflow_status:
runs-on: 'gemini-cli-ubuntu-16-core'
permissions: 'write-all'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
needs:
- 'parse_run_context'
- 'e2e'
steps:
- name: 'Set workflow status'
uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
with:
allowForks: 'true'
repo: '${{ github.repository }}'
+7 -9
View File
@@ -37,7 +37,6 @@ jobs:
permissions: 'read-all'
name: 'Merge Queue Skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
outputs:
skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}'
steps:
@@ -50,7 +49,7 @@ jobs:
name: 'Lint'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
env:
GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters'
steps:
@@ -117,7 +116,6 @@ jobs:
link_checker:
name: 'Link Checker'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
@@ -131,7 +129,7 @@ jobs:
runs-on: 'gemini-cli-ubuntu-16-core'
needs:
- 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
permissions:
contents: 'read'
checks: 'write'
@@ -218,7 +216,7 @@ jobs:
runs-on: 'macos-latest'
needs:
- 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
permissions:
contents: 'read'
checks: 'write'
@@ -313,7 +311,7 @@ jobs:
name: 'CodeQL'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
permissions:
actions: 'read'
contents: 'read'
@@ -336,7 +334,7 @@ jobs:
bundle_size:
name: 'Check Bundle Size'
needs: 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}"
runs-on: 'gemini-cli-ubuntu-16-core'
permissions:
contents: 'read' # For checkout
@@ -361,7 +359,7 @@ jobs:
name: 'Slow Test - Win - ${{ matrix.shard }}'
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
timeout-minutes: 60
strategy:
matrix:
@@ -453,7 +451,7 @@ jobs:
ci:
name: 'CI'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
needs:
- 'lint'
- 'link_checker'
-3
View File
@@ -27,7 +27,6 @@ jobs:
deflake_e2e_linux:
name: 'E2E Test (Linux) - ${{ matrix.sandbox }}'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
strategy:
fail-fast: false
matrix:
@@ -78,7 +77,6 @@ jobs:
deflake_e2e_mac:
name: 'E2E Test (macOS)'
runs-on: 'macos-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -116,7 +114,6 @@ jobs:
deflake_e2e_windows:
name: 'Slow E2E - Win'
runs-on: 'gemini-cli-windows-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
+50
View File
@@ -0,0 +1,50 @@
name: 'Deploy GitHub Pages'
on:
push:
tags: 'v*'
workflow_dispatch:
permissions:
contents: 'read'
pages: 'write'
id-token: 'write'
# Allow only one concurrent deployment, skipping runs queued between the run
# in-progress and latest queued. However, do NOT cancel in-progress runs as we
# want to allow these production deployments to complete.
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: false
jobs:
build:
if: |-
${{ !contains(github.ref_name, 'nightly') }}
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
- name: 'Setup Pages'
uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5
- name: 'Build with Jekyll'
uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1
with:
source: './'
destination: './_site'
- name: 'Upload artifact'
uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3
deploy:
environment:
name: 'github-pages'
url: '${{ steps.deployment.outputs.page_url }}'
runs-on: 'ubuntu-latest'
needs: 'build'
steps:
- name: 'Deploy to GitHub Pages'
id: 'deployment'
uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4
-1
View File
@@ -7,7 +7,6 @@ on:
- 'docs/**'
jobs:
trigger-rebuild:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
steps:
- name: 'Trigger rebuild'
+1 -2
View File
@@ -23,7 +23,6 @@ jobs:
evals:
name: 'Evals (USUALLY_PASSING) nightly run'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
strategy:
fail-fast: false
matrix:
@@ -86,7 +85,7 @@ jobs:
aggregate-results:
name: 'Aggregate Results'
needs: ['evals']
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- name: 'Checkout'
@@ -21,7 +21,6 @@ defaults:
jobs:
close-stale-issues:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
@@ -14,7 +14,7 @@ permissions:
jobs:
# Event-based: Quick reaction to new/edited issues in THIS repo
labeler:
if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'"
if: "github.event_name == 'issues'"
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
@@ -36,7 +36,7 @@ jobs:
# Scheduled/Manual: Recursive sync across multiple repos
sync-maintainer-labels:
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"
if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'"
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
@@ -9,7 +9,6 @@ on:
jobs:
labeler:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
@@ -32,7 +32,6 @@ on:
jobs:
change-tags:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
-1
View File
@@ -47,7 +47,6 @@ on:
jobs:
release:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
-1
View File
@@ -22,7 +22,6 @@ on:
jobs:
generate-release-notes:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'write'
+1 -2
View File
@@ -42,7 +42,6 @@ on:
jobs:
change-tags:
if: "github.repository == 'google-gemini/gemini-cli'"
environment: "${{ github.event.inputs.environment || 'prod' }}"
runs-on: 'ubuntu-latest'
permissions:
@@ -204,7 +203,7 @@ jobs:
run: |
ROLLBACK_COMMIT=$(git rev-parse -q --verify "$TARGET_TAG")
if [ "$ROLLBACK_COMMIT" != "$TARGET_HASH" ]; then
echo "❌ Failed to add tag ${TARGET_TAG} to commit ${TARGET_HASH}"
echo '❌ Failed to add tag $TARGET_TAG to commit $TARGET_HASH'
echo '❌ This means the tag was not added, and the workflow should fail.'
exit 1
fi
-1
View File
@@ -16,7 +16,6 @@ on:
jobs:
build:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'read'
-1
View File
@@ -20,7 +20,6 @@ on:
jobs:
smoke-test:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'write'
+2 -4
View File
@@ -15,7 +15,6 @@ on:
jobs:
save_repo_name:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- name: 'Save Repo name'
@@ -24,15 +23,14 @@ jobs:
HEAD_SHA: '${{ github.event.inputs.head_sha || github.event.pull_request.head.sha }}'
run: |
mkdir -p ./pr
echo "${REPO_NAME}" > ./pr/repo_name
echo "${HEAD_SHA}" > ./pr/head_sha
echo '${REPO_NAME}' > ./pr/repo_name
echo '${HEAD_SHA}' > ./pr/head_sha
- uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'repo_name'
path: 'pr/'
trigger_e2e:
name: 'Trigger e2e'
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- id: 'trigger-e2e'
-1
View File
@@ -28,7 +28,6 @@ on:
jobs:
verify-release:
if: "github.repository == 'google-gemini/gemini-cli'"
environment: "${{ github.event.inputs.environment || 'prod' }}"
strategy:
fail-fast: false
+7 -27
View File
@@ -80,37 +80,18 @@ manually during a session.
### Planning Workflow
Plan Mode uses an adaptive planning workflow where the research depth, plan
structure, and consultation level are proportional to the task's complexity:
1. **Explore & Analyze:** Analyze requirements and use read-only tools to map
affected modules and identify dependencies.
2. **Consult:** The depth of consultation is proportional to the task's
complexity:
- **Simple Tasks:** Proceed directly to drafting.
- **Standard Tasks:** Present a summary of viable approaches via
[`ask_user`] for selection.
- **Complex Tasks:** Present detailed trade-offs for at least two viable
approaches via [`ask_user`] and obtain approval before drafting.
3. **Draft:** Write a detailed implementation plan to the
[plans directory](#custom-plan-directory-and-policies). The plan's structure
adapts to the task:
- **Simple Tasks:** Focused on specific **Changes** and **Verification**
steps.
- **Standard Tasks:** Includes an **Objective**, **Key Files & Context**,
**Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Comprehensive plans including **Background &
Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives
Considered**, a phased **Implementation Plan**, **Verification**, and
**Migration & Rollback** strategies.
the codebase and validate assumptions. For complex tasks, identify at least
two viable implementation approaches.
2. **Consult:** Present a summary of the identified approaches via [`ask_user`]
to obtain a selection. For simple or canonical tasks, this step may be
skipped.
3. **Draft:** Once an approach is selected, write a detailed implementation
plan to the plans directory.
4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan
and formally request approval.
- **Approve:** Exit Plan Mode and start implementation.
- **Iterate:** Provide feedback to refine the plan.
- **Refine manually:** Press **Ctrl + X** to open the plan file in your
[preferred external editor]. This allows you to manually refine the plan
steps before approval. The CLI will automatically refresh and show the
updated plan after you save and close the editor.
For more complex or specialized planning tasks, you can
[customize the planning workflow with skills](#customizing-planning-with-skills).
@@ -309,4 +290,3 @@ performance. You can disable this automatic switching in your settings:
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
[auto model]: /docs/reference/configuration.md#model-settings
[model routing]: /docs/cli/telemetry.md#model-routing
[preferred external editor]: /docs/reference/configuration.md#general
-1
View File
@@ -140,7 +140,6 @@ they appear in the UI.
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
### Skills
+11 -39
View File
@@ -176,12 +176,11 @@ Sends telemetry directly to Google Cloud services. No collector needed.
}
```
2. Run Gemini CLI and send prompts.
3. View logs, metrics, and traces:
3. View logs and metrics:
- Open the Google Cloud Console in your browser after sending prompts:
- Logs (Logs Explorer): https://console.cloud.google.com/logs/
- Metrics (Metrics Explorer):
https://console.cloud.google.com/monitoring/metrics-explorer
- Traces (Trace Explorer): https://console.cloud.google.com/traces/list
- Logs: https://console.cloud.google.com/logs/
- Metrics: https://console.cloud.google.com/monitoring/metrics-explorer
- Traces: https://console.cloud.google.com/traces/list
### Collector-based export (advanced)
@@ -209,12 +208,11 @@ forward data to Google Cloud.
- Save collector logs to `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log`
- Stop collector on exit (e.g. `Ctrl+C`)
3. Run Gemini CLI and send prompts.
4. View logs, metrics, and traces:
4. View logs and metrics:
- Open the Google Cloud Console in your browser after sending prompts:
- Logs (Logs Explorer): https://console.cloud.google.com/logs/
- Metrics (Metrics Explorer):
https://console.cloud.google.com/monitoring/metrics-explorer
- Traces (Trace Explorer): https://console.cloud.google.com/traces/list
- Logs: https://console.cloud.google.com/logs/
- Metrics: https://console.cloud.google.com/monitoring/metrics-explorer
- Traces: https://console.cloud.google.com/traces/list
- Open `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log` to view local
collector logs.
@@ -272,10 +270,10 @@ For local development and debugging, you can capture telemetry data locally:
3. View traces at http://localhost:16686 and logs/metrics in the collector log
file.
## Logs, metrics, and traces
## Logs and metrics
The following section describes the structure of logs, metrics, and traces
generated for Gemini CLI.
The following section describes the structure of logs and metrics generated for
Gemini CLI.
The `session.id`, `installation.id`, `active_approval_mode`, and `user.email`
(available only when authenticated with a Google account) are included as common
@@ -826,32 +824,6 @@ Optional performance monitoring for startup, CPU/memory, and phase timing.
- `current_value` (number)
- `baseline_value` (number)
### Traces
Traces offer a granular, "under-the-hood" view of every agent and backend
operation. By providing a high-fidelity execution map, they enable precise
debugging of complex tool interactions and deep performance optimization. Each
trace captures rich, consistent metadata via custom span attributes:
- `gen_ai.operation.name` (string): The high-level operation kind (e.g.
"tool_call", "llm_call").
- `gen_ai.agent.name` (string): The service agent identifier ("gemini-cli").
- `gen_ai.agent.description` (string): The service agent description.
- `gen_ai.input.messages` (string): Input messages or metadata specific to the
operation.
- `gen_ai.output.messages` (string): Output messages or metadata generated from
the operation.
- `gen_ai.request.model` (string): The request model name.
- `gen_ai.response.model` (string): The response model name.
- `gen_ai.system_instructions` (json string): The system instructions.
- `gen_ai.prompt.name` (string): The prompt name.
- `gen_ai.tool.name` (string): The executed tool's name.
- `gen_ai.tool.call_id` (string): The generated specific ID of the tool call.
- `gen_ai.tool.description` (string): The executed tool's description.
- `gen_ai.tool.definitions` (json string): The executed tool's description.
- `gen_ai.conversation.id` (string): The current CLI session ID.
- Additional user-defined Custom Attributes passed via the span's configuration.
#### GenAI semantic convention
The following metrics comply with [OpenTelemetry GenAI semantic conventions] for
-36
View File
@@ -227,42 +227,6 @@ skill definitions in a `skills/` directory. For example,
Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add
agent definition files (`.md`) to an `agents/` directory in your extension root.
### <a id="policy-engine"></a>Policy Engine
Extensions can contribute policy rules and safety checkers to the Gemini CLI
[Policy Engine](../reference/policy-engine.md). These rules are defined in
`.toml` files and take effect when the extension is activated.
To add policies, create a `policies/` directory in your extension's root and
place your `.toml` policy files inside it. Gemini CLI automatically loads all
`.toml` files from this directory.
Rules contributed by extensions run in their own tier (tier 2), alongside
workspace-defined policies. This tier has higher priority than the default rules
but lower priority than user or admin policies.
> **Warning:** For security, Gemini CLI ignores any `allow` decisions or `yolo`
> mode configurations in extension policies. This ensures that an extension
> cannot automatically approve tool calls or bypass security measures without
> your confirmation.
**Example `policies.toml`**
```toml
[[rule]]
toolName = "my_server__dangerous_tool"
decision = "ask_user"
priority = 100
[[safety_checker]]
toolName = "my_server__write_data"
priority = 200
[safety_checker.checker]
type = "in-process"
name = "allowed-path"
required_context = ["environment"]
```
### Themes
Extensions can provide custom themes to personalize the CLI UI. Themes are
+41 -47
View File
@@ -1,21 +1,23 @@
# Local development guide
This guide provides instructions for setting up and using local development
features, such as tracing.
features, such as development tracing.
## Tracing
## Development tracing
Traces are OpenTelemetry (OTel) records that help you debug your code by
instrumenting key events like model calls, tool scheduler operations, and tool
calls.
Development traces (dev traces) are OpenTelemetry (OTel) traces that help you
debug your code by instrumenting interesting events like model calls, tool
scheduler, tool calls, etc.
Traces provide deep visibility into agent behavior and are invaluable for
debugging complex issues. They are captured automatically when telemetry is
enabled.
Dev traces are verbose and are specifically meant for understanding agent
behavior and debugging issues. They are disabled by default.
### Viewing traces
To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable
when running Gemini CLI.
You can view traces using either Jaeger or the Genkit Developer UI.
### Viewing dev traces
You can view dev traces using either Jaeger or the Genkit Developer UI.
#### Using Genkit
@@ -35,12 +37,13 @@ Genkit provides a web-based UI for viewing traces and other telemetry data.
Genkit Developer UI: http://localhost:4000
```
2. **Run Gemini CLI:**
2. **Run Gemini CLI with dev tracing:**
In a separate terminal, run your Gemini CLI command:
In a separate terminal, run your Gemini CLI command with the
`GEMINI_DEV_TRACING` environment variable:
```bash
gemini
GEMINI_DEV_TRACING=true gemini
```
3. **View the traces:**
@@ -50,7 +53,7 @@ Genkit provides a web-based UI for viewing traces and other telemetry data.
#### Using Jaeger
You can view traces in the Jaeger UI. To get started, follow these steps:
You can view dev traces in the Jaeger UI. To get started, follow these steps:
1. **Start the telemetry collector:**
@@ -64,12 +67,13 @@ You can view traces in the Jaeger UI. To get started, follow these steps:
This command also configures your workspace for local telemetry and provides
a link to the Jaeger UI (usually `http://localhost:16686`).
2. **Run Gemini CLI:**
2. **Run Gemini CLI with dev tracing:**
In a separate terminal, run your Gemini CLI command:
In a separate terminal, run your Gemini CLI command with the
`GEMINI_DEV_TRACING` environment variable:
```bash
gemini
GEMINI_DEV_TRACING=true gemini
```
3. **View the traces:**
@@ -80,10 +84,10 @@ You can view traces in the Jaeger UI. To get started, follow these steps:
For more detailed information on telemetry, see the
[telemetry documentation](./cli/telemetry.md).
### Instrumenting code with traces
### Instrumenting code with dev traces
You can add traces to your own code for more detailed instrumentation. This is
useful for debugging and understanding the flow of execution.
You can add dev traces to your own code for more detailed instrumentation. This
is useful for debugging and understanding the flow of execution.
Use the `runInDevTraceSpan` function to wrap any section of code in a trace
span.
@@ -92,39 +96,29 @@ Here is a basic example:
```typescript
import { runInDevTraceSpan } from '@google/gemini-cli-core';
import { GeminiCliOperation } from '@google/gemini-cli-core/lib/telemetry/constants.js';
await runInDevTraceSpan(
{
operation: GeminiCliOperation.ToolCall,
attributes: {
[GEN_AI_AGENT_NAME]: 'gemini-cli',
},
},
async ({ metadata }) => {
// The `metadata` object allows you to record the input and output of the
// operation as well as other attributes.
metadata.input = { key: 'value' };
// Set custom attributes.
metadata.attributes['custom.attribute'] = 'custom.value';
await runInDevTraceSpan({ name: 'my-custom-span' }, async ({ metadata }) => {
// The `metadata` object allows you to record the input and output of the
// operation as well as other attributes.
metadata.input = { key: 'value' };
// Set custom attributes.
metadata.attributes['gen_ai.request.model'] = 'gemini-4.0-mega';
// Your code to be traced goes here
try {
const output = await somethingRisky();
metadata.output = output;
return output;
} catch (e) {
metadata.error = e;
throw e;
}
},
);
// Your code to be traced goes here
try {
const output = await somethingRisky();
metadata.output = output;
return output;
} catch (e) {
metadata.error = e;
throw e;
}
});
```
In this example:
- `operation`: The operation type of the span, represented by the
`GeminiCliOperation` enum.
- `name`: The name of the span, which will be displayed in the trace.
- `metadata.input`: (Optional) An object containing the input data for the
traced operation.
- `metadata.output`: (Optional) An object containing the output data from the
-17
View File
@@ -1014,23 +1014,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.enabled`** (boolean):
- **Description:** Enable the Gemma Model Router. Requires a local endpoint
serving Gemma via the Gemini API using LiteRT-LM shim.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.classifier.host`** (string):
- **Description:** The host of the classifier.
- **Default:** `"http://localhost:9379"`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.classifier.model`** (string):
- **Description:** The model to use for the classifier. Only tested on
`gemma3-1b-gpu-custom`.
- **Default:** `"gemma3-1b-gpu-custom"`
- **Requires restart:** Yes
#### `skills`
- **`skills.enabled`** (boolean):
+6 -6
View File
@@ -87,12 +87,12 @@ available combinations.
#### Text Input
| Action | Keys |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Alt + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
| Open the current prompt or the plan in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V`<br />`Alt + V` |
| Action | Keys |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Alt + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
| Open the current prompt in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V`<br />`Alt + V` |
#### App Controls
+3 -4
View File
@@ -97,10 +97,9 @@ has a designated number that forms the base of the final priority calculation.
| Tier | Base | Description |
| :-------- | :--- | :------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
| Extension | 2 | Policies defined in extensions. |
| Workspace | 3 | Policies defined in the current workspace's configuration directory. |
| User | 4 | Custom policies defined by the user. |
| Admin | 5 | Policies managed by an administrator (e.g., in an enterprise environment). |
| Workspace | 2 | Policies defined in the current workspace's configuration directory. |
| User | 3 | Custom policies defined by the user. |
| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). |
Within a TOML policy file, you assign a priority value from **0 to 999**. The
engine transforms this into a final priority using the following formula:
-9
View File
@@ -5,15 +5,6 @@ problems encountered while using Gemini CLI.
## General issues
### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI?
Using third-party software, tools, or services to access Gemini CLI is a
violation of our [applicable terms and policies](tos-privacy.md), and severely
degrades the experience for legitimate product users. Such actions may be
grounds for suspension or termination of your account. If you would like to use
a third-party coding agent with Gemini, we recommend using a Vertex or AI Studio
API key.
### Why am I getting an `API error: 429 - Resource exhausted`?
This error indicates that you have exceeded your API request limit. The Gemini
-6
View File
@@ -7,12 +7,6 @@ is licensed under the
When you use Gemini CLI to access or use Googles services, the Terms of Service
and Privacy Notices applicable to those services apply to such access and use.
Directly accessing the services powering Gemini CLI (e.g., the Gemini Code
Assist service) using third-party software, tools, or services (for example,
using OpenClaw with Gemini CLI OAuth) is a violation of applicable terms and
policies. Such actions may be grounds for suspension or termination of your
account.
Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy
Policy.
+1 -1
View File
@@ -8,7 +8,7 @@ import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('validation_fidelity', () => {
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should perform exhaustive validation autonomously when guided by system instructions',
files: {
'src/types.ts': `
+1
View File
@@ -72,6 +72,7 @@ describe('ACP telemetry', () => {
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_TELEMETRY_TARGET: 'local',
GEMINI_TELEMETRY_OUTFILE: telemetryPath,
// GEMINI_DEV_TRACING not set: fake responses aren't instrumented for spans
},
},
);
+25 -1
View File
@@ -2292,6 +2292,7 @@
"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",
@@ -2472,6 +2473,7 @@
"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"
}
@@ -2521,6 +2523,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2895,6 +2898,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2928,6 +2932,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2982,6 +2987,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4178,6 +4184,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4451,6 +4458,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -5298,6 +5306,7 @@
"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"
},
@@ -7851,6 +7860,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8483,6 +8493,7 @@
"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",
@@ -9777,6 +9788,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10056,6 +10068,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -13705,6 +13718,7 @@
"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"
}
@@ -13715,6 +13729,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15674,6 +15689,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15897,7 +15913,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -15905,6 +15922,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16064,6 +16082,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16272,6 +16291,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16385,6 +16405,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16397,6 +16418,7 @@
"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",
@@ -17041,6 +17063,7 @@
"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"
}
@@ -17440,6 +17463,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
-7
View File
@@ -15,11 +15,4 @@
- **Utilities**: Use `renderWithProviders` and `waitFor` from
`packages/cli/src/test-utils/`.
- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output.
- **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for
UI components whenever colors or detailed visual layout matter. SVG snapshots
capture styling accurately. Make sure to await the `waitUntilReady()` of the
render result before asserting. After updating SVG snapshots, always examine
the resulting `.svg` files (e.g. by reading their content or visually
inspecting them) to ensure the render and colors actually look as expected and
don't just contain an error message.
- **Mocks**: Use mocks as sparingly as possible.
@@ -1,41 +0,0 @@
# Policy engine example extension
This extension demonstrates how to contribute security rules and safety checkers
to the Gemini CLI Policy Engine.
## Description
The extension uses a `policies/` directory containing `.toml` files to define:
- A rule that requires user confirmation for `rm -rf` commands.
- A rule that denies searching for sensitive files (like `.env`) using `grep`.
- A safety checker that validates file paths for all write operations.
## Structure
- `gemini-extension.json`: The manifest file.
- `policies/`: Contains the `.toml` policy files.
## How to use
1. Link this extension to your local Gemini CLI installation:
```bash
gemini extensions link packages/cli/src/commands/extensions/examples/policies
```
2. Restart your Gemini CLI session.
3. **Observe the policies:**
- Try asking the model to delete a directory: The policy engine will prompt
you for confirmation due to the `rm -rf` rule.
- Try asking the model to search for secrets: The `grep` rule will deny the
request and display the custom deny message.
- Any file write operation will now be processed through the `allowed-path`
safety checker.
## Security note
For security, Gemini CLI ignores any `allow` decisions or `yolo` mode
configurations contributed by extensions. This ensures that extensions can
strengthen security but cannot bypass user confirmation.
@@ -1,5 +0,0 @@
{
"name": "policy-example",
"version": "1.0.0",
"description": "An example extension demonstrating Policy Engine support."
}
@@ -1,28 +0,0 @@
# Example Policy Rules for Gemini CLI Extension
#
# Extensions run in Tier 2 (Extension Tier).
# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored.
# Rule: Always ask the user before running a specific dangerous shell command.
[[rule]]
toolName = "run_shell_command"
commandPrefix = "rm -rf"
decision = "ask_user"
priority = 100
# Rule: Deny access to sensitive files using the grep tool.
[[rule]]
toolName = "grep_search"
argsPattern = "(\.env|id_rsa|passwd)"
decision = "deny"
priority = 200
deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension."
# Safety Checker: Apply path validation to all write operations.
[[safety_checker]]
toolName = ["write_file", "replace"]
priority = 300
[safety_checker.checker]
type = "in-process"
name = "allowed-path"
required_context = ["environment"]
-60
View File
@@ -2765,66 +2765,6 @@ describe('loadCliConfig approval mode', () => {
});
});
describe('loadCliConfig gemmaModelRouter', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should have gemmaModelRouter disabled by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getGemmaModelRouterEnabled()).toBe(false);
});
it('should load gemmaModelRouter settings from merged settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
gemmaModelRouter: {
enabled: true,
classifier: {
host: 'http://custom:1234',
model: 'custom-gemma',
},
},
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getGemmaModelRouterEnabled()).toBe(true);
const gemmaSettings = config.getGemmaModelRouterSettings();
expect(gemmaSettings.classifier?.host).toBe('http://custom:1234');
expect(gemmaSettings.classifier?.model).toBe('custom-gemma');
});
it('should handle partial gemmaModelRouter settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
gemmaModelRouter: {
enabled: true,
},
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getGemmaModelRouterEnabled()).toBe(true);
const gemmaSettings = config.getGemmaModelRouterSettings();
expect(gemmaSettings.classifier?.host).toBe('http://localhost:9379');
expect(gemmaSettings.classifier?.model).toBe('gemma3-1b-gpu-custom');
});
});
describe('loadCliConfig fileFiltering', () => {
const originalArgv = process.argv;
-2
View File
@@ -843,7 +843,6 @@ export async function loadCliConfig(
interactive,
trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
@@ -857,7 +856,6 @@ export async function loadCliConfig(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
+3 -36
View File
@@ -52,10 +52,6 @@ import {
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
CoreToolCallStatus,
loadExtensionPolicies,
isSubpath,
type PolicyRule,
type SafetyCheckerRule,
HookType,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
@@ -768,18 +764,9 @@ Would you like to attempt to install via "git clone" instead?`,
}
const contextFiles = getContextFileNames(config)
.map((contextFileName) => {
const contextFilePath = path.join(
effectiveExtensionPath,
contextFileName,
);
if (!isSubpath(effectiveExtensionPath, contextFilePath)) {
throw new Error(
`Invalid context file path: "${contextFileName}". Context files must be within the extension directory.`,
);
}
return contextFilePath;
})
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
const hydrationContext: VariableContext = {
@@ -833,24 +820,6 @@ Would you like to attempt to install via "git clone" instead?`,
recursivelyHydrateStrings(skill, hydrationContext),
);
let rules: PolicyRule[] | undefined;
let checkers: SafetyCheckerRule[] | undefined;
const policyDir = path.join(effectiveExtensionPath, 'policies');
if (fs.existsSync(policyDir)) {
const result = await loadExtensionPolicies(config.name, policyDir);
rules = result.rules;
checkers = result.checkers;
if (result.errors.length > 0) {
for (const error of result.errors) {
debugLogger.warn(
`[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`,
);
}
}
}
const agentLoadResult = await loadAgentsFromDirectory(
path.join(effectiveExtensionPath, 'agents'),
);
@@ -884,8 +853,6 @@ Would you like to attempt to install via "git clone" instead?`,
skills,
agents: agentLoadResult.agents,
themes: config.themes,
rules,
checkers,
};
} catch (e) {
debugLogger.error(
+4 -130
View File
@@ -239,27 +239,6 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('test-extension');
});
it('should throw an error if a context file path is outside the extension directory', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
version: '1.0.0',
contextFileName: '../secret.txt',
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'traversal-extension: Invalid context file path: "../secret.txt"',
),
);
consoleSpy.mockRestore();
});
it('should load context file path when GEMINI.md is present', async () => {
createExtension({
extensionsDir: userExtensionsDir,
@@ -384,111 +363,6 @@ describe('extension tests', () => {
]);
});
it('should load extension policies from the policies directory', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'policy-extension',
version: '1.0.0',
});
const policiesDir = path.join(extDir, 'policies');
fs.mkdirSync(policiesDir);
const policiesContent = `
[[rule]]
toolName = "deny_tool"
decision = "deny"
priority = 500
[[rule]]
toolName = "ask_tool"
decision = "ask_user"
priority = 100
`;
fs.writeFileSync(
path.join(policiesDir, 'policies.toml'),
policiesContent,
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
expect(extension.rules).toBeDefined();
expect(extension.rules).toHaveLength(2);
expect(
extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision,
).toBe('deny');
expect(
extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision,
).toBe('ask_user');
// Verify source is prefixed
expect(extension.rules![0].source).toContain(
'Extension (policy-extension):',
);
});
it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'security-test-extension',
version: '1.0.0',
});
const policiesDir = path.join(extDir, 'policies');
fs.mkdirSync(policiesDir);
const policiesContent = `
[[rule]]
toolName = "allow_tool"
decision = "allow"
priority = 100
[[rule]]
toolName = "yolo_tool"
decision = "ask_user"
priority = 100
modes = ["yolo"]
[[safety_checker]]
toolName = "yolo_check"
priority = 100
modes = ["yolo"]
[safety_checker.checker]
type = "external"
name = "yolo-checker"
`;
fs.writeFileSync(
path.join(policiesDir, 'policies.toml'),
policiesContent,
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
// ALLOW rules and YOLO rules/checkers should be filtered out
expect(extension.rules).toBeDefined();
expect(extension.rules).toHaveLength(0);
expect(extension.checkers).toBeDefined();
expect(extension.checkers).toHaveLength(0);
// Should have logged warnings
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('attempted to contribute an ALLOW rule'),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('attempted to contribute a rule for YOLO mode'),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'attempted to contribute a safety checker for YOLO mode',
),
);
consoleSpy.mockRestore();
});
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
const sourceExtDir = getRealPath(
createExtension({
@@ -666,7 +540,7 @@ name = "yolo-checker"
// Bad extension
const badExtDir = path.join(userExtensionsDir, 'bad-ext');
fs.mkdirSync(badExtDir, { recursive: true });
fs.mkdirSync(badExtDir);
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed
@@ -674,7 +548,7 @@ name = "yolo-checker"
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
),
@@ -697,7 +571,7 @@ name = "yolo-checker"
// Bad extension
const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name');
fs.mkdirSync(badExtDir, { recursive: true });
fs.mkdirSync(badExtDir);
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' }));
@@ -705,7 +579,7 @@ name = "yolo-checker"
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
+1 -1
View File
@@ -489,7 +489,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SUBMIT]: 'Submit the current prompt.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt or the plan in an external editor.',
'Open the current prompt in an external editor.',
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
// App Controls
@@ -177,13 +177,13 @@ describe('Policy Engine Integration Tests', () => {
);
const engine = new PolicyEngine(config);
// MCP server allowed (priority 4.1) provides general allow for server
// MCP server allowed (priority 4.1) provides general allow for server
// MCP server allowed (priority 3.1) provides general allow for server
// MCP server allowed (priority 3.1) provides general allow for server
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// But specific tool exclude (priority 4.4) wins over server allow
// But specific tool exclude (priority 3.4) wins over server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
@@ -476,25 +476,25 @@ describe('Policy Engine Integration Tests', () => {
// Find rules and verify their priorities
const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool');
expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude
expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude
const blockedServerRule = rules.find(
(r) => r.toolName === 'blocked-server__*',
);
expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude
expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude
const specificToolRule = rules.find(
(r) => r.toolName === 'specific-tool',
);
expect(specificToolRule?.priority).toBe(4.3); // Command line allow
expect(specificToolRule?.priority).toBe(3.3); // Command line allow
const trustedServerRule = rules.find(
(r) => r.toolName === 'trusted-server__*',
);
expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server
expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server
expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
@@ -641,16 +641,16 @@ describe('Policy Engine Integration Tests', () => {
// Verify each rule has the expected priority
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier)
expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier)
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier)
expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier)
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier)
expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier)
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07
+1 -32
View File
@@ -12,8 +12,6 @@ import {
resolveWorkspacePolicyState,
autoAcceptWorkspacePolicies,
setAutoAcceptWorkspacePolicies,
disableWorkspacePolicies,
setDisableWorkspacePolicies,
} from './policy.js';
import { writeToStderr } from '@google/gemini-cli-core';
@@ -47,9 +45,6 @@ describe('resolveWorkspacePolicyState', () => {
fs.mkdirSync(workspaceDir);
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
// Enable policies for these tests to verify loading logic
setDisableWorkspacePolicies(false);
vi.clearAllMocks();
});
@@ -72,13 +67,6 @@ describe('resolveWorkspacePolicyState', () => {
});
});
it('should have disableWorkspacePolicies set to true by default', () => {
// We explicitly set it to false in beforeEach for other tests,
// so here we test that setting it to true works.
setDisableWorkspacePolicies(true);
expect(disableWorkspacePolicies).toBe(true);
});
it('should return policy directory if integrity matches', async () => {
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
@@ -200,26 +188,7 @@ describe('resolveWorkspacePolicyState', () => {
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => {
setDisableWorkspacePolicies(true);
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
expect(result).toEqual({
workspacePoliciesDir: undefined,
policyUpdateConfirmationRequest: undefined,
});
});
it('should return empty state if cwd is a symlink to the home directory', async () => {
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+1 -15
View File
@@ -35,20 +35,6 @@ export function setAutoAcceptWorkspacePolicies(value: boolean) {
autoAcceptWorkspacePolicies = value;
}
/**
* Temporary flag to disable workspace level policies altogether.
* Exported as 'let' to allow monkey patching in tests via the setter.
*/
export let disableWorkspacePolicies = true;
/**
* Sets the disableWorkspacePolicies flag.
* Used primarily for testing purposes.
*/
export function setDisableWorkspacePolicies(value: boolean) {
disableWorkspacePolicies = value;
}
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
@@ -95,7 +81,7 @@ export async function resolveWorkspacePolicyState(options: {
| PolicyUpdateConfirmationRequest
| undefined;
if (trustedFolder && !disableWorkspacePolicies) {
if (trustedFolder) {
const storage = new Storage(cwd);
// If we are in the home directory (or rather, our target Gemini dir is the global one),
@@ -444,60 +444,6 @@ describe('SettingsSchema', () => {
expect(hookItemProperties.description).toBeDefined();
expect(hookItemProperties.description.type).toBe('string');
});
it('should have gemmaModelRouter setting in schema', () => {
const gemmaModelRouter =
getSettingsSchema().experimental.properties.gemmaModelRouter;
expect(gemmaModelRouter).toBeDefined();
expect(gemmaModelRouter.type).toBe('object');
expect(gemmaModelRouter.category).toBe('Experimental');
expect(gemmaModelRouter.default).toEqual({});
expect(gemmaModelRouter.requiresRestart).toBe(true);
expect(gemmaModelRouter.showInDialog).toBe(true);
expect(gemmaModelRouter.description).toBe(
'Enable Gemma model router (experimental).',
);
const enabled = gemmaModelRouter.properties.enabled;
expect(enabled).toBeDefined();
expect(enabled.type).toBe('boolean');
expect(enabled.category).toBe('Experimental');
expect(enabled.default).toBe(false);
expect(enabled.requiresRestart).toBe(true);
expect(enabled.showInDialog).toBe(true);
expect(enabled.description).toBe(
'Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.',
);
const classifier = gemmaModelRouter.properties.classifier;
expect(classifier).toBeDefined();
expect(classifier.type).toBe('object');
expect(classifier.category).toBe('Experimental');
expect(classifier.default).toEqual({});
expect(classifier.requiresRestart).toBe(true);
expect(classifier.showInDialog).toBe(false);
expect(classifier.description).toBe('Classifier configuration.');
const host = classifier.properties.host;
expect(host).toBeDefined();
expect(host.type).toBe('string');
expect(host.category).toBe('Experimental');
expect(host.default).toBe('http://localhost:9379');
expect(host.requiresRestart).toBe(true);
expect(host.showInDialog).toBe(false);
expect(host.description).toBe('The host of the classifier.');
const model = classifier.properties.model;
expect(model).toBeDefined();
expect(model.type).toBe('string');
expect(model.category).toBe('Experimental');
expect(model.default).toBe('gemma3-1b-gpu-custom');
expect(model.requiresRestart).toBe(true);
expect(model.showInDialog).toBe(false);
expect(model.description).toBe(
'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.',
);
});
});
it('has JSON schema definitions for every referenced ref', () => {
+2 -57
View File
@@ -1787,57 +1787,6 @@ const SETTINGS_SCHEMA = {
'Enable web fetch behavior that bypasses LLM summarization.',
showInDialog: true,
},
gemmaModelRouter: {
type: 'object',
label: 'Gemma Model Router',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Enable Gemma model router (experimental).',
showInDialog: true,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Gemma Model Router',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.',
showInDialog: true,
},
classifier: {
type: 'object',
label: 'Classifier',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Classifier configuration.',
showInDialog: false,
properties: {
host: {
type: 'string',
label: 'Host',
category: 'Experimental',
requiresRestart: true,
default: 'http://localhost:9379',
description: 'The host of the classifier.',
showInDialog: false,
},
model: {
type: 'string',
label: 'Model',
category: 'Experimental',
requiresRestart: true,
default: 'gemma3-1b-gpu-custom',
description:
'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.',
showInDialog: false,
},
},
},
},
},
},
},
@@ -2583,9 +2532,7 @@ type InferSettings<T extends SettingsSchema> = {
: T[K]['default']
: T[K]['default'] extends boolean
? boolean
: T[K]['default'] extends string
? string
: T[K]['default'];
: T[K]['default'];
};
type InferMergedSettings<T extends SettingsSchema> = {
@@ -2597,9 +2544,7 @@ type InferMergedSettings<T extends SettingsSchema> = {
: T[K]['default']
: T[K]['default'] extends boolean
? boolean
: T[K]['default'] extends string
? string
: T[K]['default'];
: T[K]['default'];
};
export type Settings = InferSettings<SettingsSchemaType>;
@@ -54,7 +54,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
Policy.setDisableWorkspacePolicies(false);
// Default to MATCH for existing tests
mockCheckIntegrity.mockResolvedValue({
status: 'match',
+1 -5
View File
@@ -1182,7 +1182,6 @@ describe('startInteractiveUI', () => {
getProjectRoot: () => '/root',
getScreenReader: () => false,
getDebugMode: () => false,
getUseAlternateBuffer: () => true,
});
const mockSettings = {
merged: {
@@ -1217,8 +1216,6 @@ describe('startInteractiveUI', () => {
runExitCleanup: vi.fn(),
registerSyncCleanup: vi.fn(),
registerTelemetryConfig: vi.fn(),
setupSignalHandlers: vi.fn(),
setupTtyCheck: vi.fn(() => vi.fn()),
}));
beforeEach(() => {
@@ -1325,8 +1322,7 @@ describe('startInteractiveUI', () => {
// Verify all startup tasks were called
expect(getVersion).toHaveBeenCalledTimes(1);
// 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check
expect(registerCleanup).toHaveBeenCalledTimes(5);
expect(registerCleanup).toHaveBeenCalledTimes(4);
// Verify cleanup handler is registered with unmount function
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
+7 -10
View File
@@ -32,8 +32,6 @@ import {
registerSyncCleanup,
runExitCleanup,
registerTelemetryConfig,
setupSignalHandlers,
setupTtyCheck,
} from './utils/cleanup.js';
import {
cleanupToolOutputFiles,
@@ -102,8 +100,8 @@ import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
@@ -196,7 +194,7 @@ export async function startInteractiveUI(
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
isAlternateBufferEnabled(settings),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
@@ -321,8 +319,6 @@ export async function startInteractiveUI(
});
registerCleanup(() => instance.unmount());
registerCleanup(setupTtyCheck());
}
export async function main() {
@@ -344,8 +340,6 @@ export async function main() {
setupUnhandledRejectionHandler();
setupSignalHandlers();
const slashCommandConflictHandler = new SlashCommandConflictHandler();
slashCommandConflictHandler.start();
registerCleanup(() => slashCommandConflictHandler.stop());
@@ -652,7 +646,10 @@ export async function main() {
process.stdin.setRawMode(true);
// This cleanup isn't strictly needed but may help in certain situations.
registerSyncCleanup(() => {
process.on('SIGTERM', () => {
process.stdin.setRawMode(wasRaw);
});
process.on('SIGINT', () => {
process.stdin.setRawMode(wasRaw);
});
}
@@ -678,7 +675,7 @@ export async function main() {
let input = config.getQuestion();
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
isAlternateBufferEnabled(settings),
config.getScreenReader(),
);
const rawStartupWarnings = await getStartupWarnings();
@@ -156,7 +156,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getExperiments: vi.fn().mockReturnValue(undefined),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
...overrides,
}) as unknown as Config;
+2 -22
View File
@@ -547,11 +547,6 @@ const baseMockUiState = {
},
hintMode: false,
hintBuffer: '',
bannerData: {
defaultText: '',
warningText: '',
},
bannerVisible: false,
};
export const mockAppState: AppState = {
@@ -703,21 +698,6 @@ export const renderWithProviders = (
});
}
// Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value,
// without replacing the entire config object and its other values.
let finalConfig = config;
if (useAlternateBuffer !== undefined) {
finalConfig = new Proxy(config, {
get(target, prop, receiver) {
if (prop === 'getUseAlternateBuffer') {
return () => useAlternateBuffer;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver);
},
});
}
const mainAreaWidth = terminalWidth;
const finalUiState = {
@@ -746,7 +726,7 @@ export const renderWithProviders = (
const renderResult = render(
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={finalConfig}>
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
@@ -758,7 +738,7 @@ export const renderWithProviders = (
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={finalConfig}
config={config}
toolCalls={allToolCalls}
>
<AskUserActionsProvider
@@ -2675,10 +2675,6 @@ describe('AppContainer State Management', () => {
isAlternateMode = false,
childHandler?: Mock,
) => {
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
isAlternateMode,
);
// Update settings for this test run
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
@@ -3368,8 +3364,6 @@ describe('AppContainer State Management', () => {
);
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
@@ -3602,8 +3596,6 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
+71 -21
View File
@@ -145,6 +145,7 @@ import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
@@ -227,7 +228,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const isAlternateBuffer = useAlternateBuffer();
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -263,16 +264,14 @@ export const AppContainer = (props: AppContainerProps) => {
() => isWorkspaceTrusted(settings.merged).isTrusted,
);
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
QUEUE_ERROR_DISPLAY_DURATION_MS,
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
null,
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
EXPAND_HINT_DURATION_MS,
);
const showIsExpandableHint = Boolean(expandHintTrigger);
const [showIsExpandableHint, setShowIsExpandableHint] = useState(false);
const expandHintTimerRef = useRef<NodeJS.Timeout | null>(null);
const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
@@ -285,15 +284,39 @@ export const AppContainer = (props: AppContainerProps) => {
* boolean dependency (hasOverflowState) to ensure the timer only resets on
* genuine state transitions, preventing it from infinitely resetting during
* active text streaming.
*
* In alternate buffer mode, we don't trigger the hint automatically on overflow
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
*/
useEffect(() => {
if (hasOverflowState && !isAlternateBuffer) {
triggerExpandHint(true);
if (isAlternateBuffer) {
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
return;
}
}, [hasOverflowState, isAlternateBuffer, triggerExpandHint]);
if (hasOverflowState) {
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
}, [hasOverflowState, isAlternateBuffer, constrainHeight]);
/**
* Safe cleanup to ensure the expansion hint timer is cancelled when the
* component unmounts, preventing memory leaks.
*/
useEffect(
() => () => {
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
},
[],
);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -544,7 +567,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config);
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings);
// Derive widths for InputPrompt using shared helper
const { inputWidth, suggestionsWidth } = useMemo(() => {
const { inputWidth, suggestionsWidth } =
@@ -1229,7 +1252,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
async (submittedValue: string) => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
triggerExpandHint(null);
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
if (!constrainHeight) {
setConstrainHeight(true);
if (!isAlternateBuffer) {
@@ -1301,14 +1327,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
reset,
handleHintSubmit,
triggerExpandHint,
],
);
const handleClearScreen = useCallback(() => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
triggerExpandHint(null);
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
historyManager.clearItems();
clearConsoleMessagesState();
refreshStatic();
@@ -1317,7 +1345,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
clearConsoleMessagesState,
refreshStatic,
reset,
triggerExpandHint,
setShowIsExpandableHint,
]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@@ -1604,6 +1632,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [ideNeedsRestart]);
useEffect(() => {
if (queueErrorMessage) {
const timer = setTimeout(() => {
setQueueErrorMessage(null);
}, QUEUE_ERROR_DISPLAY_DURATION_MS);
return () => clearTimeout(timer);
}
return undefined;
}, [queueErrorMessage, setQueueErrorMessage]);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
@@ -1709,7 +1748,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
if (!isAlternateBuffer) {
refreshStatic();
@@ -1758,7 +1803,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
) {
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
if (!isAlternateBuffer) {
refreshStatic();
}
@@ -1863,7 +1914,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
showTransientMessage,
settings.merged.general.devtools,
showErrorDetails,
triggerExpandHint,
],
);
@@ -6,8 +6,8 @@
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { render } from '../../test-utils/render.js';
import { act, useEffect } from 'react';
import { Box, Text } from 'ink';
import { useEffect } from 'react';
import { Composer } from './Composer.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import {
@@ -34,7 +34,6 @@ import { StreamingState } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
const composerTestControls = vi.hoisted(() => ({
suggestionsVisible: false,
@@ -264,26 +263,16 @@ const renderComposer = async (
</ConfigContext.Provider>,
);
await result.waitUntilReady();
// Wait for shortcuts hint debounce if using fake timers
if (vi.isFakeTimers()) {
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
}
return result;
};
describe('Composer', () => {
beforeEach(() => {
vi.useFakeTimers();
composerTestControls.suggestionsVisible = false;
composerTestControls.isAlternateBuffer = false;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -402,7 +391,7 @@ describe('Composer', () => {
expect(output).not.toContain('ShortcutsHint');
});
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
it('renders LoadingIndicator without thought when loadingPhrases is off', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: { subject: 'Hidden', description: 'Should not show' },
@@ -415,7 +404,7 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).toContain('LoadingIndicator: Hidden');
expect(output).not.toContain('Should not show');
});
it('does not render LoadingIndicator when waiting for confirmation', async () => {
@@ -820,28 +809,6 @@ describe('Composer', () => {
});
describe('Shortcuts Hint', () => {
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
const { lastFrame } = await renderComposer(
createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
}),
);
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
});
it('does not show shortcuts hint immediately when buffer has text', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({
@@ -890,27 +857,6 @@ describe('Composer', () => {
expect(lastFrame()).toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading when full UI details are visible', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
streamingState: StreamingState.Responding,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when text is typed in buffer', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
@@ -984,10 +930,9 @@ describe('Composer', () => {
streamingState: StreamingState.Idle,
});
const { lastFrame, unmount } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help while streaming', async () => {
@@ -996,10 +941,9 @@ describe('Composer', () => {
streamingState: StreamingState.Responding,
});
const { lastFrame, unmount } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help when action is required', async () => {
@@ -1012,10 +956,9 @@ describe('Composer', () => {
),
});
const { lastFrame, unmount } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
});
+6 -23
View File
@@ -151,30 +151,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
: undefined,
);
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
const isModelIdle = uiState.streamingState === StreamingState.Idle;
const isBufferEmpty = uiState.buffer.text.length === 0;
const canShowShortcutsHint =
isModelIdle && isBufferEmpty && !hasPendingActionRequired;
const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
useState(canShowShortcutsHint);
useEffect(() => {
if (!canShowShortcutsHint) {
setShowShortcutsHintDebounced(false);
return;
}
const timeout = setTimeout(() => {
setShowShortcutsHintDebounced(true);
}, 200);
return () => clearTimeout(timeout);
}, [canShowShortcutsHint]);
const showShortcutsHint =
settings.merged.ui.showShortcutsHint &&
!hideShortcutsHintForSuggestions &&
showShortcutsHintDebounced;
!hideMinimalModeHintWhileBusy &&
!hasPendingActionRequired;
const showMinimalModeBleedThrough =
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
@@ -229,7 +210,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation
StreamingState.WaitingForConfirmation ||
settings.merged.ui.loadingPhrases === 'off'
? undefined
: uiState.thought
}
@@ -272,7 +254,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation
StreamingState.WaitingForConfirmation ||
settings.merged.ui.loadingPhrases === 'off'
? undefined
: uiState.thought
}
@@ -135,7 +135,6 @@ export const DialogManager = ({
isModelNotFoundError={
!!uiState.quota.proQuotaRequest.isModelNotFoundError
}
authType={uiState.quota.proQuotaRequest.authType}
onChoice={uiActions.handleProQuotaChoice}
/>
);
@@ -167,7 +167,6 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
},
);
@@ -444,7 +443,6 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
},
);
@@ -13,7 +13,6 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
AuthType,
} from '@google/gemini-cli-core';
// Mock the child component to make it easier to test the parent
@@ -63,7 +62,7 @@ describe('ProQuotaDialog', () => {
describe('for non-flash model failures', () => {
describe('when it is a terminal quota error', () => {
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE', () => {
it('should render switch, upgrade, and stop options for paid tiers', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
@@ -71,7 +70,6 @@ describe('ProQuotaDialog', () => {
message="paid tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
authType={AuthType.LOGIN_WITH_GOOGLE}
onChoice={mockOnChoice}
/>,
);
@@ -101,39 +99,6 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should NOT render upgrade option for USE_GEMINI', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="paid tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
authType={AuthType.USE_GEMINI}
onChoice={mockOnChoice}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-flash',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
it('should render "Keep trying" and "Stop" options when failed model and fallback model are the same', () => {
const { unmount } = render(
<ProQuotaDialog
@@ -165,7 +130,7 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE (free tier)', () => {
it('should render switch, upgrade, and stop options for free tier', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
@@ -173,7 +138,6 @@ describe('ProQuotaDialog', () => {
message="free tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
authType={AuthType.LOGIN_WITH_GOOGLE}
onChoice={mockOnChoice}
/>,
);
@@ -240,7 +204,7 @@ describe('ProQuotaDialog', () => {
});
describe('when it is a model not found error', () => {
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE', () => {
it('should render switch and stop options regardless of tier', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
@@ -248,7 +212,6 @@ describe('ProQuotaDialog', () => {
message="You don't have access to gemini-3-pro-preview yet."
isTerminalQuotaError={false}
isModelNotFoundError={true}
authType={AuthType.LOGIN_WITH_GOOGLE}
onChoice={mockOnChoice}
/>,
);
@@ -278,7 +241,7 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should NOT render upgrade option for USE_GEMINI', () => {
it('should render switch and stop options for paid tier as well', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
@@ -286,7 +249,6 @@ describe('ProQuotaDialog', () => {
message="You don't have access to gemini-3-pro-preview yet."
isTerminalQuotaError={false}
isModelNotFoundError={true}
authType={AuthType.USE_GEMINI}
onChoice={mockOnChoice}
/>,
);
@@ -299,6 +261,11 @@ describe('ProQuotaDialog', () => {
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Upgrade for higher limits',
value: 'upgrade',
key: 'upgrade',
},
{
label: 'Stop',
value: 'retry_later',
@@ -8,7 +8,6 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { AuthType } from '@google/gemini-cli-core';
interface ProQuotaDialogProps {
failedModel: string;
@@ -16,7 +15,6 @@ interface ProQuotaDialogProps {
message: string;
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
authType?: AuthType;
onChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
@@ -28,7 +26,6 @@ export function ProQuotaDialog({
message,
isTerminalQuotaError,
isModelNotFoundError,
authType,
onChoice,
}: ProQuotaDialogProps): React.JSX.Element {
let items;
@@ -54,15 +51,11 @@ export function ProQuotaDialog({
value: 'retry_always' as const,
key: 'retry_always',
},
...(authType === AuthType.LOGIN_WITH_GOOGLE
? [
{
label: 'Upgrade for higher limits',
value: 'upgrade' as const,
key: 'upgrade',
},
]
: []),
{
label: 'Upgrade for higher limits',
value: 'upgrade' as const,
key: 'upgrade',
},
{
label: `Stop`,
value: 'retry_later' as const,
@@ -187,9 +187,7 @@ describe('ToastDisplay', () => {
constrainHeight: true,
});
await waitUntilReady();
expect(lastFrame()).toContain(
'Ctrl+O to show more lines of the last response',
);
expect(lastFrame()).toContain('Press Ctrl+O to show more lines');
});
it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => {
@@ -198,8 +196,6 @@ describe('ToastDisplay', () => {
constrainHeight: false,
});
await waitUntilReady();
expect(lastFrame()).toContain(
'Ctrl+O to collapse lines of the last response',
);
expect(lastFrame()).toContain('Press Ctrl+O to collapse lines');
});
});
@@ -78,7 +78,7 @@ export const ToastDisplay: React.FC = () => {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.accent}>
Ctrl+O to {action} lines of the last response
Press Ctrl+O to {action} lines for the most recent response
</Text>
);
}
@@ -51,7 +51,6 @@ describe('ToolConfirmationQueue', () => {
storage: {
getPlansDir: () => '/mock/temp/plans',
},
getUseAlternateBuffer: () => false,
} as unknown as Config;
beforeEach(() => {
@@ -228,7 +227,7 @@ describe('ToolConfirmationQueue', () => {
// availableContentHeight = Math.max(9 - 6, 4) = 4
// MaxSizedBox in ToolConfirmationMessage will use 4
// It should show truncation message
await waitFor(() => expect(lastFrame()).toContain('49 hidden (Ctrl+O)'));
await waitFor(() => expect(lastFrame()).toContain('first 49 lines hidden'));
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -35,7 +35,7 @@ Footer
`;
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
" LoadingIndicator: Thinking
" LoadingIndicator: Thinking ShortcutsHint
────────────────────────────────────────────────────────────────────────────────────────────────────
ApprovalModeIndicator
InputPrompt: Type your message or @path/to/file
@@ -74,7 +74,7 @@ Implementation Steps
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
7. Create token refresh mechanism in src/auth/TokenManager.ts
8. Add multi-factor authentication in src/auth/MFAService.ts
... last 22 lines hidden (Ctrl+O to show) ...
... last 22 lines hidden ...
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
@@ -112,7 +112,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
"✦ Example code block:
... 42 hidden (Ctrl+O) ...
... first 42 lines hidden ...
43 Line 43
44 Line 44
45 Line 45
@@ -126,7 +126,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
" Example code block:
... 42 hidden (Ctrl+O) ...
... first 42 lines hidden ...
43 Line 43
44 Line 44
45 Line 45
@@ -49,7 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ ... first 11 lines hidden (Ctrl+O to show) ...
│ ... first 11 lines hidden ...
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -6,7 +6,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ │
│ ? replace edit file │
│ │
│ ... 49 hidden (Ctrl+O) ...
│ ... first 49 lines hidden ...
│ 50 line │
│ Apply this change? │
│ │
@@ -96,7 +96,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
│ │
│ ? replace edit file │
│ │
│ ... 49 hidden (Ctrl+O) ...
│ ... first 49 lines hidden ...
│ 50 line │
│ Apply this change? │
│ │
@@ -106,7 +106,7 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
);
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
@@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
@@ -31,14 +32,16 @@ describe('ToolResultDisplay Overflow', () => {
},
];
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
isExpandable={true}
/>,
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
uiState: {
streamingState: StreamingState.Idle,
@@ -48,13 +51,12 @@ describe('ToolResultDisplay Overflow', () => {
},
);
await waitUntilReady();
// ResizeObserver might take a tick, though ToolGroupMessage calculates overflow synchronously
await waitFor(() => {
const frame = lastFrame();
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
});
// ResizeObserver might take a tick
await waitFor(() =>
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
const frame = lastFrame();
expect(frame).toBeDefined();
@@ -10,7 +10,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
"... 10 hidden (Ctrl+O) ...
"... first 10 lines hidden ...
'test';
21 + const anotherNew =
'test';
@@ -20,7 +20,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
"... first 4 lines hidden (Ctrl+O to show) ...
"... first 4 lines hidden ...
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
@@ -103,7 +103,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
"... 10 hidden (Ctrl+O) ...
"... first 10 lines hidden ...
'test';
21 + const anotherNew =
'test';
@@ -113,7 +113,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
"... first 4 lines hidden (Ctrl+O to show) ...
"... first 4 lines hidden ...
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
@@ -37,7 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... 248 hidden (Ctrl+O) ...
"... first 248 lines hidden ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -41,9 +41,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -61,9 +59,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... last 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -81,9 +77,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -99,9 +93,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 1 line hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 1 line hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -119,9 +111,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 7 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 7 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -207,9 +197,7 @@ describe('<MaxSizedBox />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 21 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 21 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -230,9 +218,7 @@ describe('<MaxSizedBox />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 21 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... last 21 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -261,9 +247,7 @@ describe('<MaxSizedBox />', () => {
const lastLine = lines[lines.length - 1];
// The last line should only contain the hidden indicator, no leaked content
expect(lastLine).toMatch(
/^\.\.\. last \d+ lines? hidden \(Ctrl\+O to show\) \.\.\.$/,
);
expect(lastLine).toMatch(/^\.\.\. last \d+ lines? hidden \.\.\.$/);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -9,9 +9,6 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { isNarrowWidth } from '../../utils/isNarrowWidth.js';
import { Command } from '../../../config/keyBindings.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
/**
* Minimum height for the MaxSizedBox component.
@@ -87,9 +84,6 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
const isNarrow = maxWidth !== undefined && isNarrowWidth(maxWidth);
const showMoreKey = formatCommand(Command.SHOW_MORE_LINES);
useEffect(() => {
if (totalHiddenLines > 0) {
addOverflowingId?.(id);
@@ -122,9 +116,8 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
>
{totalHiddenLines > 0 && overflowDirection === 'top' && (
<Text color={theme.text.secondary} wrap="truncate">
{isNarrow
? `... ${totalHiddenLines} hidden (${showMoreKey}) ...`
: `... first ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`}
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
<Box
@@ -144,9 +137,8 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
</Box>
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
<Text color={theme.text.secondary} wrap="truncate">
{isNarrow
? `... ${totalHiddenLines} hidden (${showMoreKey}) ...`
: `... last ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`}
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
</Box>
@@ -6,11 +6,20 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { Scrollable } from './Scrollable.js';
import { Text, Box } from 'ink';
import { Text } from 'ink';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as ScrollProviderModule from '../../contexts/ScrollProvider.js';
import { act } from 'react';
import { waitFor } from '../../../test-utils/async.js';
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
getInnerHeight: vi.fn(() => 5),
getScrollHeight: vi.fn(() => 10),
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })),
};
});
vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({
useAnimatedScrollbar: (
@@ -120,26 +129,20 @@ describe('<Scrollable />', () => {
</Scrollable>,
);
await waitUntilReady2();
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(5);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
// Call scrollBy multiple times (upwards) in the same tick
await act(async () => {
capturedEntry?.scrollBy(-1);
capturedEntry?.scrollBy(-1);
capturedEntry!.scrollBy(-1);
capturedEntry!.scrollBy(-1);
});
// Should have moved up by 2 (5 -> 3)
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(3);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
await act(async () => {
capturedEntry?.scrollBy(-2);
});
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(1);
capturedEntry!.scrollBy(-2);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
unmount2();
});
@@ -188,6 +191,10 @@ describe('<Scrollable />', () => {
keySequence,
expectedScrollTop,
}) => {
// Dynamically import ink to mock getScrollHeight
const ink = await import('ink');
vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight);
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
async (entry, isActive) => {
@@ -199,9 +206,7 @@ describe('<Scrollable />', () => {
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={true} height={5}>
<Box height={scrollHeight}>
<Text>Content</Text>
</Box>
<Text>Content</Text>
</Scrollable>,
);
await waitUntilReady();
@@ -4,9 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import React, {
useState,
useEffect,
useRef,
useLayoutEffect,
useCallback,
useMemo,
} from 'react';
import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
@@ -35,101 +41,62 @@ export const Scrollable: React.FC<ScrollableProps> = ({
flexGrow,
}) => {
const [scrollTop, setScrollTop] = useState(0);
const viewportRef = useRef<DOMElement | null>(null);
const contentRef = useRef<DOMElement | null>(null);
const ref = useRef<DOMElement>(null);
const [size, setSize] = useState({
innerHeight: typeof height === 'number' ? height : 0,
innerHeight: 0,
scrollHeight: 0,
});
const sizeRef = useRef(size);
const scrollTopRef = useRef(scrollTop);
useLayoutEffect(() => {
useEffect(() => {
sizeRef.current = size;
}, [size]);
const childrenCountRef = useRef(0);
// This effect needs to run on every render to correctly measure the container
// and scroll to the bottom if new children are added.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
scrollTopRef.current = scrollTop;
}, [scrollTop]);
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
const viewportRefCallback = useCallback((node: DOMElement | null) => {
viewportObserverRef.current?.disconnect();
viewportRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const innerHeight = Math.round(entry.contentRect.height);
setSize((prev) => {
const scrollHeight = prev.scrollHeight;
const isAtBottom =
scrollHeight > prev.innerHeight &&
scrollTopRef.current >= scrollHeight - prev.innerHeight - 1;
if (isAtBottom) {
setScrollTop(Number.MAX_SAFE_INTEGER);
}
return { ...prev, innerHeight };
});
}
});
observer.observe(node);
viewportObserverRef.current = observer;
if (!ref.current) {
return;
}
}, []);
const innerHeight = Math.round(getInnerHeight(ref.current));
const scrollHeight = Math.round(getScrollHeight(ref.current));
const contentRefCallback = useCallback(
(node: DOMElement | null) => {
contentObserverRef.current?.disconnect();
contentRef.current = node;
const isAtBottom =
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const scrollHeight = Math.round(entry.contentRect.height);
setSize((prev) => {
const innerHeight = prev.innerHeight;
const isAtBottom =
prev.scrollHeight > innerHeight &&
scrollTopRef.current >= prev.scrollHeight - innerHeight - 1;
if (
isAtBottom ||
(scrollToBottom && scrollHeight > prev.scrollHeight)
) {
setScrollTop(Number.MAX_SAFE_INTEGER);
}
return { ...prev, scrollHeight };
});
}
});
observer.observe(node);
contentObserverRef.current = observer;
if (
size.innerHeight !== innerHeight ||
size.scrollHeight !== scrollHeight
) {
setSize({ innerHeight, scrollHeight });
if (isAtBottom) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
}
},
[scrollToBottom],
);
}
const childCountCurrent = React.Children.count(children);
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
}
childrenCountRef.current = childCountCurrent;
});
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
const scrollBy = useCallback(
(delta: number) => {
const { scrollHeight, innerHeight } = sizeRef.current;
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const current = Math.min(getScrollTop(), maxScroll);
let next = Math.max(0, current + delta);
if (next >= maxScroll) {
next = Number.MAX_SAFE_INTEGER;
}
const current = getScrollTop();
const next = Math.min(
Math.max(0, current + delta),
Math.max(0, scrollHeight - innerHeight),
);
setPendingScrollTop(next);
setScrollTop(next);
},
[getScrollTop, setPendingScrollTop],
[sizeRef, getScrollTop, setPendingScrollTop],
);
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
@@ -140,11 +107,10 @@ export const Scrollable: React.FC<ScrollableProps> = ({
const { scrollHeight, innerHeight } = sizeRef.current;
const scrollTop = getScrollTop();
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const actualScrollTop = Math.min(scrollTop, maxScroll);
// Only capture scroll-up events if there's room;
// otherwise allow events to bubble.
if (actualScrollTop > 0) {
if (scrollTop > 0) {
if (keyMatchers[Command.PAGE_UP](key)) {
scrollByWithAnimation(-innerHeight);
return true;
@@ -157,7 +123,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
// Only capture scroll-down events if there's room;
// otherwise allow events to bubble.
if (actualScrollTop < maxScroll) {
if (scrollTop < maxScroll) {
if (keyMatchers[Command.PAGE_DOWN](key)) {
scrollByWithAnimation(innerHeight);
return true;
@@ -174,21 +140,21 @@ export const Scrollable: React.FC<ScrollableProps> = ({
{ isActive: hasFocus },
);
const getScrollState = useCallback(() => {
const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
const getScrollState = useCallback(
() => ({
scrollTop: getScrollTop(),
scrollHeight: size.scrollHeight,
innerHeight: size.innerHeight,
};
}, [getScrollTop, size.scrollHeight, size.innerHeight]);
}),
[getScrollTop, size.scrollHeight, size.innerHeight],
);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: viewportRef as React.RefObject<DOMElement>,
ref: ref as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
hasFocus: hasFocusCallback,
@@ -201,7 +167,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
return (
<Box
ref={viewportRefCallback}
ref={ref}
maxHeight={maxHeight}
width={width ?? maxWidth}
height={height}
@@ -217,12 +183,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
based on the children's content. It also adds a right padding to
make room for the scrollbar.
*/}
<Box
ref={contentRefCallback}
flexShrink={0}
paddingRight={1}
flexDirection="column"
>
<Box flexShrink={0} paddingRight={1} flexDirection="column">
{children}
</Box>
</Box>
@@ -479,277 +479,4 @@ describe('ScrollableList Demo Behavior', () => {
});
});
});
it('regression: remove last item and add 2 items when scrolled to bottom', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
null;
const TestComp = () => {
const [items, setItems] = useState<Item[]>(
Array.from({ length: 10 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
})),
);
useEffect(() => {
setItemsFunc = setItems;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={5}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Scrolled to bottom, max scroll = 10 - 5 = 5
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(5);
});
// Remove last element and add 2 elements
await act(async () => {
setItemsFunc!((prev) => {
const next = prev.slice(0, prev.length - 1);
next.push({ id: '10', title: 'Item 10' });
next.push({ id: '11', title: 'Item 11' });
return next;
});
});
await result!.waitUntilReady();
// Auto scrolls to new bottom: max scroll = 11 - 5 = 6
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
});
// Scroll up slightly
await act(async () => {
listRef?.scrollBy(-2);
});
await result!.waitUntilReady();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(4);
});
// Scroll back to bottom
await act(async () => {
listRef?.scrollToEnd();
});
await result!.waitUntilReady();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
});
// Add two more elements
await act(async () => {
setItemsFunc!((prev) => [
...prev,
{ id: '12', title: 'Item 12' },
{ id: '13', title: 'Item 13' },
]);
});
await result!.waitUntilReady();
// Auto scrolls to bottom: max scroll = 13 - 5 = 8
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
});
result!.unmount();
});
it('regression: bottom-most element changes size but list does not update', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let expandLastFunc: (() => void) | null = null;
const ItemWithState = ({
item,
isLast,
}: {
item: Item;
isLast: boolean;
}) => {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (isLast) {
expandLastFunc = () => setExpanded(true);
}
}, [isLast]);
return (
<Box flexDirection="column">
<Text>{item.title}</Text>
{expanded && <Text>Expanded content</Text>}
</Box>
);
};
const TestComp = () => {
// items array is stable
const [items] = useState(() =>
Array.from({ length: 5 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
})),
);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={4}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item, index }) => (
<ItemWithState item={item} isLast={index === 4} />
)}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Initially, total height is 5. viewport is 4. scroll is 1.
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(1);
});
// Expand the last item locally, without re-rendering the list!
await act(async () => {
expandLastFunc!();
});
await result!.waitUntilReady();
// The total height becomes 6. It should remain scrolled to bottom, so scroll becomes 2.
// This is expected to FAIL currently because VirtualizedList won't remeasure
// unless data changes or container height changes.
await waitFor(
() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(2);
},
{ timeout: 1000 },
);
result!.unmount();
});
it('regression: prepending items does not corrupt heights (total height correct)', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
null;
const TestComp = () => {
// Items 1 to 5. Item 1 is very tall.
const [items, setItems] = useState<Item[]>(
Array.from({ length: 5 }, (_, i) => ({
id: String(i + 1),
title: `Item ${i + 1}`,
})),
);
useEffect(() => {
setItemsFunc = setItems;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => (
<Box height={item.id === '1' ? 10 : 2}>
<Text>{item.title}</Text>
</Box>
)}
estimatedItemHeight={() => 2}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Scroll is at bottom.
// Heights: Item 1: 10, Item 2: 2, Item 3: 2, Item 4: 2, Item 5: 2.
// Total height = 18. Container = 10. Max scroll = 8.
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
});
// Prepend an item!
await act(async () => {
setItemsFunc!((prev) => [{ id: '0', title: 'Item 0' }, ...prev]);
});
await result!.waitUntilReady();
// Now items: 0(2), 1(10), 2(2), 3(2), 4(2), 5(2).
// Total height = 20. Container = 10. Max scroll = 10.
// Auto-scrolls to bottom because it was sticking!
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(10);
});
result!.unmount();
});
});
@@ -10,7 +10,7 @@ import {
useImperativeHandle,
useCallback,
useMemo,
useLayoutEffect,
useEffect,
} from 'react';
import type React from 'react';
import {
@@ -105,7 +105,7 @@ function ScrollableList<T>(
smoothScrollState.current.active = false;
}, []);
useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]);
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
const smoothScrollTo = useCallback(
(
@@ -120,19 +120,15 @@ function ScrollableList<T>(
innerHeight: 0,
};
const {
scrollTop: rawStartScrollTop,
scrollTop: startScrollTop,
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
) {
if (targetScrollTop === SCROLL_TO_ITEM_END) {
effectiveTarget = maxScrollTop;
}
@@ -142,11 +138,8 @@ function ScrollableList<T>(
);
if (duration === 0) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
} else {
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
}
@@ -175,11 +168,8 @@ function ScrollableList<T>(
ease;
if (progress >= 1) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
} else {
virtualizedListRef.current?.scrollTo(Math.round(current));
}
@@ -210,13 +200,9 @@ function ScrollableList<T>(
) {
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);
: scrollState.scrollTop;
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
return true;
@@ -5,7 +5,6 @@
*/
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
import { Text, Box } from 'ink';
import {
@@ -276,11 +275,9 @@ describe('<VirtualizedList />', () => {
await waitUntilReady();
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
await waitFor(() => {
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
});
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
unmount();
});
@@ -10,6 +10,7 @@ import {
useLayoutEffect,
forwardRef,
useImperativeHandle,
useEffect,
useMemo,
useCallback,
} from 'react';
@@ -18,7 +19,7 @@ import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type DOMElement, Box, ResizeObserver } from 'ink';
import { type DOMElement, measureElement, Box } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
@@ -80,7 +81,7 @@ function VirtualizedList<T>(
} = props;
const { copyModeEnabled } = useUIState();
const dataRef = useRef(data);
useLayoutEffect(() => {
useEffect(() => {
dataRef.current = data;
}, [data]);
@@ -107,7 +108,6 @@ function VirtualizedList<T>(
return { index: 0, offset: 0 };
});
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
@@ -116,75 +116,73 @@ function VirtualizedList<T>(
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
return scrollToEnd;
});
const containerRef = useRef<DOMElement | null>(null);
const containerRef = useRef<DOMElement>(null);
const [containerHeight, setContainerHeight] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const [heights, setHeights] = useState<number[]>([]);
const isInitialScrollSet = useRef(false);
const containerObserverRef = useRef<ResizeObserver | null>(null);
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
const containerRefCallback = useCallback((node: DOMElement | null) => {
containerObserverRef.current?.disconnect();
containerRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerHeight(Math.round(entry.contentRect.height));
}
});
observer.observe(node);
containerObserverRef.current = observer;
}
}, []);
const itemsObserver = useMemo(
() =>
new ResizeObserver((entries) => {
setHeights((prev) => {
let next: Record<string, number> | null = null;
for (const entry of entries) {
const key = nodeToKeyRef.current.get(entry.target);
if (key !== undefined) {
const height = Math.round(entry.contentRect.height);
if (prev[key] !== height) {
if (!next) {
next = { ...prev };
}
next[key] = height;
}
}
}
return next ?? prev;
});
}),
[],
);
useLayoutEffect(
() => () => {
containerObserverRef.current?.disconnect();
itemsObserver.disconnect();
},
[itemsObserver],
);
const { totalHeight, offsets } = useMemo(() => {
const offsets: number[] = [0];
let totalHeight = 0;
for (let i = 0; i < data.length; i++) {
const key = keyExtractor(data[i], i);
const height = heights[key] ?? estimatedItemHeight(i);
const height = heights[i] ?? estimatedItemHeight(i);
totalHeight += height;
offsets.push(totalHeight);
}
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight, keyExtractor]);
}, [heights, data, estimatedItemHeight]);
const scrollableContainerHeight = containerHeight;
useEffect(() => {
setHeights((prevHeights) => {
if (data.length === prevHeights.length) {
return prevHeights;
}
const newHeights = [...prevHeights];
if (data.length < prevHeights.length) {
newHeights.length = data.length;
} else {
for (let i = prevHeights.length; i < data.length; i++) {
newHeights[i] = estimatedItemHeight(i);
}
}
return newHeights;
});
}, [data, estimatedItemHeight]);
// This layout effect needs to run on every render to correctly measure the
// container and ensure we recompute the layout if it has changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (containerRef.current) {
const height = Math.round(measureElement(containerRef.current).height);
if (containerHeight !== height) {
setContainerHeight(height);
}
}
let newHeights: number[] | null = null;
for (let i = startIndex; i <= endIndex; i++) {
const itemRef = itemRefs.current[i];
if (itemRef) {
const height = Math.round(measureElement(itemRef).height);
if (height !== heights[i]) {
if (!newHeights) {
newHeights = [...heights];
}
newHeights[i] = height;
}
}
}
if (newHeights) {
setHeights(newHeights);
}
});
const scrollableContainerHeight = containerRef.current
? Math.round(measureElement(containerRef.current).height)
: containerHeight;
const getAnchorForScrollTop = useCallback(
(
@@ -201,36 +199,23 @@ function VirtualizedList<T>(
[],
);
const actualScrollTop = useMemo(() => {
const scrollTop = useMemo(() => {
const offset = offsets[scrollAnchor.index];
if (typeof offset !== 'number') {
return 0;
}
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
const item = data[scrollAnchor.index];
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
const itemHeight = heights[key] ?? 0;
const itemHeight = heights[scrollAnchor.index] ?? 0;
return offset + itemHeight - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
}, [
scrollAnchor,
offsets,
heights,
scrollableContainerHeight,
data,
keyExtractor,
]);
const scrollTop = isStickingToBottom
? Number.MAX_SAFE_INTEGER
: actualScrollTop;
}, [scrollAnchor, offsets, heights, scrollableContainerHeight]);
const prevDataLength = useRef(data.length);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(actualScrollTop);
const prevScrollTop = useRef(scrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
useLayoutEffect(() => {
@@ -241,7 +226,9 @@ function VirtualizedList<T>(
prevTotalHeight.current - prevContainerHeight.current - 1;
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
// If the user was at the bottom, they are now sticking. This handles
// manually scrolling back to the bottom.
if (wasAtBottom && scrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
}
@@ -249,6 +236,9 @@ function VirtualizedList<T>(
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
// We scroll to the end if:
// 1. The list grew AND we were already at the bottom (or sticking).
// 2. We are sticking to the bottom AND the container size changed.
if (
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
@@ -257,28 +247,34 @@ function VirtualizedList<T>(
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
// If we are scrolling to the bottom, we are by definition sticking.
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
} else if (
}
// Scenario 2: The list has changed (shrunk) in a way that our
// current scroll position or anchor is invalid. We should adjust to the bottom.
else if (
(scrollAnchor.index >= data.length ||
actualScrollTop > totalHeight - scrollableContainerHeight) &&
scrollTop > totalHeight - scrollableContainerHeight) &&
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
} else if (data.length === 0) {
// List is now empty, reset scroll to top.
setScrollAnchor({ index: 0, offset: 0 });
}
// Update refs for the next render cycle.
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
prevScrollTop.current = actualScrollTop;
prevScrollTop.current = scrollTop;
prevContainerHeight.current = scrollableContainerHeight;
}, [
data.length,
totalHeight,
actualScrollTop,
scrollTop,
scrollableContainerHeight,
scrollAnchor.index,
getAnchorForScrollTop,
@@ -338,10 +334,10 @@ function VirtualizedList<T>(
const startIndex = Math.max(
0,
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
findLastIndex(offsets, (offset) => offset <= scrollTop) - 1,
);
const endIndexOffset = offsets.findIndex(
(offset) => offset > actualScrollTop + scrollableContainerHeight,
(offset) => offset > scrollTop + scrollableContainerHeight,
);
const endIndex =
endIndexOffset === -1
@@ -352,32 +348,6 @@ function VirtualizedList<T>(
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
// Maintain a stable set of observed nodes using useLayoutEffect
const observedNodes = useRef<Set<DOMElement>>(new Set());
useLayoutEffect(() => {
const currentNodes = new Set<DOMElement>();
for (let i = startIndex; i <= endIndex; i++) {
const node = itemRefs.current[i];
const item = data[i];
if (node && item) {
currentNodes.add(node);
const key = keyExtractor(item, i);
// Always update the key mapping because React can reuse nodes at different indices/keys
nodeToKeyRef.current.set(node, key);
if (!observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
itemsObserver.unobserve(node);
nodeToKeyRef.current.delete(node);
}
}
observedNodes.current = currentNodes;
});
const renderedItems = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = data[i];
@@ -386,8 +356,6 @@ function VirtualizedList<T>(
<Box
key={keyExtractor(item, i)}
width="100%"
flexDirection="column"
flexShrink={0}
ref={(el) => {
itemRefs.current[i] = el;
}}
@@ -408,39 +376,27 @@ function VirtualizedList<T>(
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), offsets),
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
currentScrollTop + delta,
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
},
scrollTo: (offset: number) => {
const maxScroll = Math.max(0, totalHeight - 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);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
setIsStickingToBottom(false);
const newScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, offset),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
},
scrollToEnd: () => {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
@@ -460,14 +416,10 @@ function VirtualizedList<T>(
setIsStickingToBottom(false);
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
totalHeight - scrollableContainerHeight,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
@@ -489,14 +441,10 @@ function VirtualizedList<T>(
if (index !== -1) {
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
totalHeight - scrollableContainerHeight,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
@@ -506,14 +454,11 @@ function VirtualizedList<T>(
}
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => {
const maxScroll = Math.max(0, totalHeight - containerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: totalHeight,
innerHeight: containerHeight,
};
},
getScrollState: () => ({
scrollTop: getScrollTop(),
scrollHeight: totalHeight,
innerHeight: containerHeight,
}),
}),
[
offsets,
@@ -530,7 +475,7 @@ function VirtualizedList<T>(
return (
<Box
ref={containerRefCallback}
ref={containerRef}
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
overflowX="hidden"
scrollTop={copyModeEnabled ? 0 : scrollTop}
@@ -544,7 +489,7 @@ function VirtualizedList<T>(
flexShrink={0}
width="100%"
flexDirection="column"
marginTop={copyModeEnabled ? -actualScrollTop : 0}
marginTop={copyModeEnabled ? -scrollTop : 0}
>
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<MaxSizedBox /> > accounts for additionalHiddenLinesCount 1`] = `
"... first 7 lines hidden (Ctrl+O to show) ...
"... first 7 lines hidden ...
Line 3
"
`;
@@ -16,12 +16,12 @@ Line 6
Line 7
Line 8
Line 9
... last 21 lines hidden (Ctrl+O to show) ...
... last 21 lines hidden ...
"
`;
exports[`<MaxSizedBox /> > clips a long single text child from the top 1`] = `
"... first 21 lines hidden (Ctrl+O to show) ...
"... first 21 lines hidden ...
Line 22
Line 23
Line 24
@@ -39,7 +39,7 @@ exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bot
- Step 1: Do something important
- Step 2: Do something important
... last 18 lines hidden (Ctrl+O to show) ...
... last 18 lines hidden ...
"
`;
@@ -58,12 +58,12 @@ Line 3 direct child
exports[`<MaxSizedBox /> > hides lines at the end when content exceeds maxHeight and overflowDirection is bottom 1`] = `
"Line 1
... last 2 lines hidden (Ctrl+O to show) ...
... last 2 lines hidden ...
"
`;
exports[`<MaxSizedBox /> > hides lines when content exceeds maxHeight 1`] = `
"... first 2 lines hidden (Ctrl+O to show) ...
"... first 2 lines hidden ...
Line 3
"
`;
@@ -74,13 +74,13 @@ exports[`<MaxSizedBox /> > renders children without truncation when they fit 1`]
`;
exports[`<MaxSizedBox /> > shows plural "lines" when more than one line is hidden 1`] = `
"... first 2 lines hidden (Ctrl+O to show) ...
"... first 2 lines hidden ...
Line 3
"
`;
exports[`<MaxSizedBox /> > shows singular "line" when exactly one line is hidden 1`] = `
"... first 1 line hidden (Ctrl+O to show) ...
"... first 1 line hidden ...
Line 1
"
`;
@@ -24,7 +24,6 @@ import type {
ApprovalMode,
UserTierId,
IdeInfo,
AuthType,
FallbackIntent,
ValidationIntent,
AgentDefinition,
@@ -43,7 +42,6 @@ export interface ProQuotaDialogRequest {
message: string;
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
authType?: AuthType;
resolve: (intent: FallbackIntent) => void;
}
@@ -1,80 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import {
useAlternateBuffer,
isAlternateBufferEnabled,
} from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: vi.fn(),
}));
const mockUseConfig = vi.mocked(
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
describe('useAlternateBuffer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when config.getUseAlternateBuffer returns false', () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
});
it('should return true when config.getUseAlternateBuffer returns true', () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true);
});
it('should return the immutable config value, not react to settings changes', () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
const { result, rerender } = renderHook(() => useAlternateBuffer());
// Value should remain true even after rerender
expect(result.current).toBe(true);
rerender();
expect(result.current).toBe(true);
});
});
describe('isAlternateBufferEnabled', () => {
it('should return true when config.getUseAlternateBuffer returns true', () => {
const config = {
getUseAlternateBuffer: () => true,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(true);
});
it('should return false when config.getUseAlternateBuffer returns false', () => {
const config = {
getUseAlternateBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(false);
});
});
@@ -4,14 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
import { useSettings } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
settings.merged.ui.useAlternateBuffer === true;
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
const config = useConfig();
return isAlternateBufferEnabled(config);
const settings = useSettings();
return isAlternateBufferEnabled(settings);
};
@@ -25,7 +25,6 @@ import type {
Config,
EditorType,
AnyToolInvocation,
SpanMetadata,
} from '@google/gemini-cli-core';
import {
CoreToolCallStatus,
@@ -40,7 +39,6 @@ import {
coreEvents,
CoreEvent,
MCPDiscoveryState,
GeminiCliOperation,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
@@ -103,19 +101,6 @@ const MockValidationRequiredError = vi.hoisted(
},
);
const mockRunInDevTraceSpan = vi.hoisted(() =>
vi.fn(async (opts, fn) => {
const metadata: SpanMetadata = {
name: opts.operation,
attributes: opts.attributes || {},
};
return await fn({
metadata,
endSpan: vi.fn(),
});
}),
);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
return {
@@ -128,7 +113,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
tokenLimit: vi.fn().mockReturnValue(100), // Mock tokenLimit
recordToolCallInteractions: vi.fn().mockResolvedValue(undefined),
getCodeAssistServer: vi.fn().mockReturnValue(undefined),
runInDevTraceSpan: mockRunInDevTraceSpan,
};
});
@@ -810,23 +794,6 @@ describe('useGeminiStream', () => {
item.text.includes('Got it. Focusing on tests only.'),
),
).toBe(true);
expect(mockRunInDevTraceSpan).toHaveBeenCalledWith(
expect.objectContaining({
operation: GeminiCliOperation.SystemPrompt,
}),
expect.any(Function),
);
const spanArgs = mockRunInDevTraceSpan.mock.calls[0];
const fn = spanArgs[1];
const metadata = { attributes: {} };
await act(async () => {
await fn({ metadata, endSpan: vi.fn() });
});
expect(metadata).toMatchObject({
input: sentParts,
});
});
it('should handle all tool calls being cancelled', async () => {
@@ -2485,11 +2452,6 @@ describe('useGeminiStream', () => {
// This is the core fix validation: Rationale comes before tools are even scheduled (awaited)
expect(rationaleIndex).toBeLessThan(scheduleIndex);
expect(rationaleIndex).toBeLessThan(toolGroupIndex);
// Ensure all state updates from recursive submitQuery are settled
await waitFor(() => {
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
});
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {
@@ -3592,31 +3554,4 @@ describe('useGeminiStream', () => {
expect(result.current.pendingHistoryItems.length).toEqual(0);
});
});
it('should trace UserPrompt telemetry on submitQuery', async () => {
const { result } = renderTestHook();
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'Response' };
})(),
);
await act(async () => {
await result.current.submitQuery('telemetry test query');
});
const userPromptCall = mockRunInDevTraceSpan.mock.calls.find(
(call) =>
call[0].operation === GeminiCliOperation.UserPrompt ||
call[0].operation === 'UserPrompt',
);
expect(userPromptCall).toBeDefined();
const spanMetadata = {} as SpanMetadata;
await act(async () => {
await userPromptCall![1]({ metadata: spanMetadata, endSpan: vi.fn() });
});
expect(spanMetadata.input).toBe('telemetry test query');
});
});
+1 -6
View File
@@ -36,7 +36,6 @@ import {
CoreToolCallStatus,
buildUserSteeringHintPrompt,
generateSteeringAckMessage,
GeminiCliOperation,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type {
@@ -1263,11 +1262,7 @@ export const useGeminiStream = (
prompt_id?: string,
) =>
runInDevTraceSpan(
{
operation: options?.isContinuation
? GeminiCliOperation.SystemPrompt
: GeminiCliOperation.UserPrompt,
},
{ name: 'submitQuery' },
async ({ metadata: spanMetadata }) => {
spanMetadata.input = query;
@@ -10,7 +10,6 @@ import {
type CodeAssistServer,
UserTierId,
getCodeAssistServer,
debugLogger,
} from '@google/gemini-cli-core';
export interface PrivacyState {
@@ -104,12 +103,7 @@ async function getRemoteDataCollectionOptIn(
): Promise<boolean> {
try {
const resp = await server.getCodeAssistGlobalUserSetting();
if (resp.freeTierDataCollectionOptin === undefined) {
debugLogger.warn(
'Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to true.',
);
}
return resp.freeTierDataCollectionOptin ?? true;
return resp.freeTierDataCollectionOptin;
} catch (error: unknown) {
if (error && typeof error === 'object' && 'response' in error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -134,10 +128,5 @@ async function setRemoteDataCollectionOptIn(
cloudaicompanionProject: server.projectId,
freeTierDataCollectionOptin: optIn,
});
if (resp.freeTierDataCollectionOptin === undefined) {
debugLogger.warn(
`Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to ${optIn}.`,
);
}
return resp.freeTierDataCollectionOptin ?? optIn;
return resp.freeTierDataCollectionOptin;
}
@@ -96,13 +96,9 @@ describe('useQuotaAndFallback', () => {
});
describe('Fallback Handler Logic', () => {
it('should show fallback dialog but omit switch to API key message if authType is not LOGIN_WITH_GOOGLE', async () => {
// Override the default mock from beforeEach for this specific test
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_GEMINI,
});
const { result } = renderHook(() =>
// Helper function to render the hook and extract the registered handler
const getRegisteredHandler = (): FallbackModelHandler => {
renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
@@ -111,24 +107,20 @@ describe('useQuotaAndFallback', () => {
onShowAuthSelection: mockOnShowAuthSelection,
}),
);
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
};
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
const error = new TerminalQuotaError(
'pro quota',
mockGoogleApiError,
1000 * 60 * 5,
);
act(() => {
void handler('gemini-pro', 'gemini-flash', error);
it('should return null and take no action if authType is not LOGIN_WITH_GOOGLE', async () => {
// Override the default mock from beforeEach for this specific test
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_GEMINI,
});
expect(result.current.proQuotaRequest).not.toBeNull();
expect(result.current.proQuotaRequest?.message).not.toContain(
'/auth to switch to API key.',
);
const handler = getRegisteredHandler();
const result = await handler('gemini-pro', 'gemini-flash', new Error());
expect(result).toBeNull();
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
describe('Interactive Fallback', () => {
@@ -55,7 +55,14 @@ export function useQuotaAndFallback({
fallbackModel,
error,
): Promise<FallbackIntent | null> => {
// Fallbacks are currently only handled for OAuth users.
const contentGeneratorConfig = config.getContentGeneratorConfig();
if (
!contentGeneratorConfig ||
contentGeneratorConfig.authType !== AuthType.LOGIN_WITH_GOOGLE
) {
return null;
}
let message: string;
let isTerminalQuotaError = false;
@@ -71,9 +78,7 @@ export function useQuotaAndFallback({
error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null,
`/stats model for usage details`,
`/model to switch models.`,
contentGeneratorConfig?.authType === AuthType.LOGIN_WITH_GOOGLE
? `/auth to switch to API key.`
: null,
`/auth to switch to API key.`,
].filter(Boolean);
message = messageLines.join('\n');
} else if (error instanceof ModelNotFoundError) {
@@ -117,7 +122,6 @@ export function useQuotaAndFallback({
message,
isTerminalQuotaError,
isModelNotFoundError,
authType: contentGeneratorConfig?.authType,
});
},
);
+4 -6
View File
@@ -15,16 +15,14 @@ export function useTimedMessage<T>(durationMs: number) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const showMessage = useCallback(
(msg: T | null) => {
(msg: T) => {
setMessage(msg);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (msg !== null) {
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, durationMs);
}
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, durationMs);
},
[durationMs],
);
@@ -6,9 +6,18 @@
import React from 'react';
import { Text } from 'ink';
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
import { theme } from '../semantic-colors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { stripUnsafeCharacters } from './textUtils.js';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
interface RenderInlineProps {
text: string;
defaultColor?: string;
@@ -19,9 +28,147 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
defaultColor,
}) => {
const text = stripUnsafeCharacters(rawText);
const ansiText = parseMarkdownToANSI(text, defaultColor);
const baseColor = defaultColor ?? theme.text.primary;
// Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) {
return <Text color={baseColor}>{text}</Text>;
}
return <Text>{ansiText}</Text>;
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
const inlineRegex =
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
let match;
while ((match = inlineRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(
<Text key={`t-${lastIndex}`} color={baseColor}>
{text.slice(lastIndex, match.index)}
</Text>,
);
}
const fullMatch = match[0];
let renderedNode: React.ReactNode = null;
const key = `m-${match.index}`;
try {
if (
fullMatch.startsWith('**') &&
fullMatch.endsWith('**') &&
fullMatch.length > BOLD_MARKER_LENGTH * 2
) {
renderedNode = (
<Text key={key} bold color={baseColor}>
{fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)}
</Text>
);
} else if (
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
!/\w/.test(text.substring(match.index - 1, match.index)) &&
!/\w/.test(
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1),
) &&
!/\S[./\\]/.test(text.substring(match.index - 2, match.index)) &&
!/[./\\]\S/.test(
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
)
) {
renderedNode = (
<Text key={key} italic color={baseColor}>
{fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)}
</Text>
);
} else if (
fullMatch.startsWith('~~') &&
fullMatch.endsWith('~~') &&
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
) {
renderedNode = (
<Text key={key} strikethrough color={baseColor}>
{fullMatch.slice(
STRIKETHROUGH_MARKER_LENGTH,
-STRIKETHROUGH_MARKER_LENGTH,
)}
</Text>
);
} else if (
fullMatch.startsWith('`') &&
fullMatch.endsWith('`') &&
fullMatch.length > INLINE_CODE_MARKER_LENGTH
) {
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
renderedNode = (
<Text key={key} color={theme.text.accent}>
{codeMatch[2]}
</Text>
);
}
} else if (
fullMatch.startsWith('[') &&
fullMatch.includes('](') &&
fullMatch.endsWith(')')
) {
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
const linkText = linkMatch[1];
const url = linkMatch[2];
renderedNode = (
<Text key={key} color={baseColor}>
{linkText}
<Text color={theme.text.link}> ({url})</Text>
</Text>
);
}
} else if (
fullMatch.startsWith('<u>') &&
fullMatch.endsWith('</u>') &&
fullMatch.length >
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 // -1 because length is compared to combined length of start and end tags
) {
renderedNode = (
<Text key={key} underline color={baseColor}>
{fullMatch.slice(
UNDERLINE_TAG_START_LENGTH,
-UNDERLINE_TAG_END_LENGTH,
)}
</Text>
);
} else if (fullMatch.match(/^https?:\/\//)) {
renderedNode = (
<Text key={key} color={theme.text.link}>
{fullMatch}
</Text>
);
}
} catch (e) {
debugLogger.warn('Error parsing inline markdown part:', fullMatch, e);
renderedNode = null;
}
nodes.push(
renderedNode ?? (
<Text key={key} color={baseColor}>
{fullMatch}
</Text>
),
);
lastIndex = inlineRegex.lastIndex;
}
if (lastIndex < text.length) {
nodes.push(
<Text key={`t-${lastIndex}`} color={baseColor}>
{text.slice(lastIndex)}
</Text>,
);
}
return <>{nodes.filter((node) => node !== null)}</>;
};
export const RenderInline = React.memo(RenderInlineInternal);
@@ -267,6 +267,7 @@ describe('TableRenderer', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Comprehensive Architectural');
expect(output).toContain('protocol buffers');
expect(output).toContain('exponential backoff');
@@ -377,134 +378,4 @@ describe('TableRenderer', () => {
await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
it.each([
{
name: 'renders complex markdown in rows and calculates widths correctly',
headers: ['Feature', 'Markdown'],
rows: [
['Bold', '**Bold Text**'],
['Italic', '_Italic Text_'],
['Combined', '***Bold and Italic***'],
['Link', '[Google](https://google.com)'],
['Code', '`const x = 1`'],
['Strikethrough', '~~Strike~~'],
['Underline', '<u>Underline</u>'],
],
terminalWidth: 80,
waitForText: 'Bold Text',
assertions: (output: string) => {
expect(output).not.toContain('**Bold Text**');
expect(output).toContain('Bold Text');
expect(output).not.toContain('_Italic Text_');
expect(output).toContain('Italic Text');
expect(output).toContain('Bold and Italic');
expect(output).toContain('Google');
expect(output).toContain('https://google.com');
expect(output).toContain('(https://google.com)');
expect(output).toContain('const x = 1');
expect(output).not.toContain('`const x = 1`');
expect(output).toContain('Strike');
expect(output).toContain('Underline');
},
},
{
name: 'calculates column widths based on rendered text, not raw markdown',
headers: ['Col 1', 'Col 2', 'Col 3'],
rows: [
['**123456**', 'Normal', 'Short'],
['Short', '**123456**', 'Normal'],
['Normal', 'Short', '**123456**'],
],
terminalWidth: 40,
waitForText: '123456',
assertions: (output: string) => {
expect(output).toContain('123456');
const dataLines = output.split('\n').filter((l) => /123456/.test(l));
expect(dataLines.length).toBe(3);
},
},
{
name: 'handles nested markdown styles recursively',
headers: ['Header 1', 'Header 2', 'Header 3'],
rows: [
['**Bold with _Italic_ and ~~Strike~~**', 'Normal', 'Short'],
['Short', '**Bold with _Italic_ and ~~Strike~~**', 'Normal'],
['Normal', 'Short', '**Bold with _Italic_ and ~~Strike~~**'],
],
terminalWidth: 100,
waitForText: 'Bold with Italic and Strike',
assertions: (output: string) => {
expect(output).not.toContain('**');
expect(output).not.toContain('_');
expect(output).not.toContain('~~');
expect(output).toContain('Bold with Italic and Strike');
},
},
{
name: 'calculates width correctly for content with URLs and styles',
headers: ['Col 1', 'Col 2', 'Col 3'],
rows: [
['Visit [Google](https://google.com)', 'Plain Text', 'More Info'],
['Info Here', 'Visit [Bing](https://bing.com)', 'Links'],
['Check This', 'Search', 'Visit [Yahoo](https://yahoo.com)'],
],
terminalWidth: 120,
waitForText: 'Visit Google',
assertions: (output: string) => {
expect(output).toContain('Visit Google');
expect(output).toContain('Visit Bing');
expect(output).toContain('Visit Yahoo');
expect(output).toContain('https://google.com');
expect(output).toContain('https://bing.com');
expect(output).toContain('https://yahoo.com');
expect(output).toContain('(https://google.com)');
const dataLine = output
.split('\n')
.find((l) => l.includes('Visit Google'));
expect(dataLine).toContain('Visit Google');
},
},
{
name: 'does not parse markdown inside code snippets',
headers: ['Col 1', 'Col 2', 'Col 3'],
rows: [
['`**not bold**`', '`_not italic_`', '`~~not strike~~`'],
['`[not link](url)`', '`<u>not underline</u>`', '`https://not.link`'],
['Normal Text', 'More Code: `*test*`', '`***nested***`'],
],
terminalWidth: 100,
waitForText: '**not bold**',
assertions: (output: string) => {
expect(output).toContain('**not bold**');
expect(output).toContain('_not italic_');
expect(output).toContain('~~not strike~~');
expect(output).toContain('[not link](url)');
expect(output).toContain('<u>not underline</u>');
expect(output).toContain('https://not.link');
expect(output).toContain('***nested***');
},
},
])(
'$name',
async ({ headers, rows, terminalWidth, waitForText, assertions }) => {
const renderResult = renderWithProviders(
<TableRenderer
headers={headers}
rows={rows}
terminalWidth={terminalWidth}
/>,
{ width: terminalWidth },
);
const { lastFrame, waitUntilReady, unmount } = renderResult;
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(waitForText);
assertions(output);
await expect(renderResult).toMatchSvgSnapshot();
unmount();
},
);
});
+24 -40
View File
@@ -5,19 +5,18 @@
*/
import React, { useMemo } from 'react';
import { styledCharsToString } from '@alcalzone/ansi-tokenize';
import { Text, Box } from 'ink';
import {
Text,
Box,
type StyledChar,
toStyledCharacters,
styledCharsToString,
styledCharsWidth,
wordBreakStyledChars,
wrapStyledChars,
widestLineFromStyledChars,
} from 'ink';
import { theme } from '../semantic-colors.js';
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
import { RenderInline } from './InlineMarkdownRenderer.js';
import { stripUnsafeCharacters } from './textUtils.js';
interface TableRendererProps {
@@ -30,19 +29,6 @@ const MIN_COLUMN_WIDTH = 5;
const COLUMN_PADDING = 2;
const TABLE_MARGIN = 2;
/**
* Parses markdown to StyledChar array by first converting to ANSI.
* This ensures character counts are accurate (markdown markers are removed
* and styles are applied to the character's internal style object).
*/
const parseMarkdownToStyledChars = (
text: string,
defaultColor?: string,
): StyledChar[] => {
const ansi = parseMarkdownToANSI(text, defaultColor);
return toStyledCharacters(ansi);
};
const calculateWidths = (styledChars: StyledChar[]) => {
const contentWidth = styledCharsWidth(styledChars);
@@ -67,26 +53,25 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
rows,
terminalWidth,
}) => {
// Clean headers: remove bold markers since we already render headers as bold
// and having them can break wrapping when the markers are split across lines.
const cleanedHeaders = useMemo(
() => headers.map((header) => header.replace(/\*\*(.*?)\*\*/g, '$1')),
[headers],
);
const styledHeaders = useMemo(
() =>
headers.map((header) =>
parseMarkdownToStyledChars(
stripUnsafeCharacters(header),
theme.text.link,
),
cleanedHeaders.map((header) =>
toStyledCharacters(stripUnsafeCharacters(header)),
),
[headers],
[cleanedHeaders],
);
const styledRows = useMemo(
() =>
rows.map((row) =>
row.map((cell) =>
parseMarkdownToStyledChars(
stripUnsafeCharacters(cell),
theme.text.primary,
),
),
row.map((cell) => toStyledCharacters(stripUnsafeCharacters(cell))),
),
[rows],
);
@@ -147,7 +132,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
const scale =
(availableWidth - finalTotalShortColumnWidth) /
(totalMinWidth - finalTotalShortColumnWidth) || 0;
(totalMinWidth - finalTotalShortColumnWidth);
finalContentWidths = constraints.map((c) => {
if (c.maxWidth <= MIN_COLUMN_WIDTH && finalTotalShortColumnWidth > 0) {
return c.minWidth;
@@ -216,7 +201,6 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
return { wrappedHeaders, wrappedRows, adjustedWidths };
}, [styledHeaders, styledRows, terminalWidth]);
// Helper function to render a cell with proper width
const renderCell = (
content: ProcessedLine,
@@ -232,10 +216,10 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
<Text>
{isHeader ? (
<Text bold color={theme.text.link}>
{content.text}
<RenderInline text={content.text} />
</Text>
) : (
<Text>{content.text}</Text>
<RenderInline text={content.text} />
)}
{' '.repeat(paddingNeeded)}
</Text>
@@ -269,18 +253,18 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
});
return (
<Box flexDirection="row">
<Text color={theme.border.default}></Text>
<Text color={theme.text.primary}>
<Text color={theme.border.default}></Text>{' '}
{renderedCells.map((cell, index) => (
<React.Fragment key={index}>
<Box paddingX={1}>{cell}</Box>
{cell}
{index < renderedCells.length - 1 && (
<Text color={theme.border.default}></Text>
<Text color={theme.border.default}>{' │ '}</Text>
)}
</React.Fragment>
))}
))}{' '}
<Text color={theme.border.default}></Text>
</Box>
</Text>
);
};
@@ -290,7 +274,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
rowIndex?: number,
isHeader = false,
): React.ReactNode => {
const key = rowIndex === -1 ? 'header' : `${rowIndex}`;
const key = isHeader ? 'header' : `${rowIndex}`;
const maxHeight = Math.max(...wrappedCells.map((lines) => lines.length), 1);
const visualRows: React.ReactNode[] = [];
@@ -0,0 +1,52 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InlineMarkdownRenderer > RenderInline > handles nested/complex markdown gracefully (best effort) 1`] = `
"Bold *Italic
*"
`;
exports[`InlineMarkdownRenderer > RenderInline > renders bold text correctly 1`] = `
"Hello
World"
`;
exports[`InlineMarkdownRenderer > RenderInline > renders inline code correctly 1`] = `
"Hello
World"
`;
exports[`InlineMarkdownRenderer > RenderInline > renders italic text correctly 1`] = `
"Hello
World"
`;
exports[`InlineMarkdownRenderer > RenderInline > renders links correctly 1`] = `"Google (https://google.com)"`;
exports[`InlineMarkdownRenderer > RenderInline > renders mixed markdown correctly 1`] = `
"Bold
and
Italic
and
Code
and
Link (https://example.com)"
`;
exports[`InlineMarkdownRenderer > RenderInline > renders plain text correctly 1`] = `"Hello World"`;
exports[`InlineMarkdownRenderer > RenderInline > renders raw URLs correctly 1`] = `
"Visit
https://google.com"
`;
exports[`InlineMarkdownRenderer > RenderInline > renders strikethrough text correctly 1`] = `
"Hello
World"
`;
exports[`InlineMarkdownRenderer > RenderInline > renders underline correctly 1`] = `
"Hello
World"
`;
exports[`InlineMarkdownRenderer > RenderInline > respects defaultColor prop 1`] = `"Hello"`;
@@ -1,39 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="380" height="156" viewBox="0 0 380 156">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="380" height="156" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="19" fill="#333333" textLength="252" lengthAdjust="spacingAndGlyphs">┌────────┬────────┬────────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 1</text>
<text x="81" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="99" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 2</text>
<text x="162" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="180" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 3</text>
<text x="243" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="252" lengthAdjust="spacingAndGlyphs">├────────┼────────┼────────┤</text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="70" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> 123456 </text>
<text x="81" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="70" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> Normal </text>
<text x="162" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="171" y="70" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> Short </text>
<text x="243" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="87" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> Short </text>
<text x="81" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="87" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> 123456 </text>
<text x="162" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="171" y="87" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> Normal </text>
<text x="243" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="104" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> Normal </text>
<text x="81" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="104" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> Short </text>
<text x="162" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="171" y="104" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> 123456 </text>
<text x="243" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="252" lengthAdjust="spacingAndGlyphs">└────────┴────────┴────────┘</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,45 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="156" viewBox="0 0 1100 156">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="1100" height="156" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="19" fill="#333333" textLength="927" lengthAdjust="spacingAndGlyphs">┌───────────────────────────────────┬───────────────────────────────┬─────────────────────────────────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 1</text>
<text x="324" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="342" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 2</text>
<text x="612" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="630" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 3</text>
<text x="918" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="927" lengthAdjust="spacingAndGlyphs">├───────────────────────────────────┼───────────────────────────────┼─────────────────────────────────┤</text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="70" fill="#ffffff" textLength="135" lengthAdjust="spacingAndGlyphs"> Visit Google (</text>
<text x="144" y="70" fill="#89b4fa" textLength="162" lengthAdjust="spacingAndGlyphs">https://google.com</text>
<text x="306" y="70" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">) </text>
<text x="324" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="333" y="70" fill="#ffffff" textLength="279" lengthAdjust="spacingAndGlyphs"> Plain Text </text>
<text x="612" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="621" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> More Info </text>
<text x="918" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="87" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs"> Info Here </text>
<text x="324" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="333" y="87" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Visit Bing (</text>
<text x="450" y="87" fill="#89b4fa" textLength="144" lengthAdjust="spacingAndGlyphs">https://bing.com</text>
<text x="594" y="87" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">) </text>
<text x="612" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="621" y="87" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> Links </text>
<text x="918" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="104" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs"> Check This </text>
<text x="324" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="333" y="104" fill="#ffffff" textLength="279" lengthAdjust="spacingAndGlyphs"> Search </text>
<text x="612" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="621" y="104" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Visit Yahoo (</text>
<text x="747" y="104" fill="#89b4fa" textLength="153" lengthAdjust="spacingAndGlyphs">https://yahoo.com</text>
<text x="900" y="104" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">) </text>
<text x="918" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="927" lengthAdjust="spacingAndGlyphs">└───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

@@ -1,40 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="156" viewBox="0 0 920 156">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="156" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="19" fill="#333333" textLength="549" lengthAdjust="spacingAndGlyphs">┌─────────────────┬──────────────────────┬──────────────────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 1</text>
<text x="162" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="180" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 2</text>
<text x="369" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="387" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Col 3</text>
<text x="540" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="549" lengthAdjust="spacingAndGlyphs">├─────────────────┼──────────────────────┼──────────────────┤</text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#cba6f7" textLength="108" lengthAdjust="spacingAndGlyphs">**not bold**</text>
<text x="162" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="180" y="70" fill="#cba6f7" textLength="108" lengthAdjust="spacingAndGlyphs">_not italic_</text>
<text x="369" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="387" y="70" fill="#cba6f7" textLength="126" lengthAdjust="spacingAndGlyphs">~~not strike~~</text>
<text x="540" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#cba6f7" textLength="135" lengthAdjust="spacingAndGlyphs">[not link](url)</text>
<text x="162" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="180" y="87" fill="#cba6f7" textLength="180" lengthAdjust="spacingAndGlyphs">&lt;u&gt;not underline&lt;/u&gt;</text>
<text x="369" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="387" y="87" fill="#cba6f7" textLength="144" lengthAdjust="spacingAndGlyphs">https://not.link</text>
<text x="540" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="104" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs"> Normal Text </text>
<text x="162" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="171" y="104" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> More Code: </text>
<text x="279" y="104" fill="#cba6f7" textLength="54" lengthAdjust="spacingAndGlyphs">*test*</text>
<text x="369" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="387" y="104" fill="#cba6f7" textLength="108" lengthAdjust="spacingAndGlyphs">***nested***</text>
<text x="540" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="549" lengthAdjust="spacingAndGlyphs">└─────────────────┴──────────────────────┴──────────────────┘</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

@@ -1,39 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="156" viewBox="0 0 920 156">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="156" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="19" fill="#333333" textLength="819" lengthAdjust="spacingAndGlyphs">┌─────────────────────────────┬─────────────────────────────┬─────────────────────────────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="72" lengthAdjust="spacingAndGlyphs">Header 1</text>
<text x="270" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="36" fill="#89b4fa" textLength="72" lengthAdjust="spacingAndGlyphs">Header 2</text>
<text x="540" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="36" fill="#89b4fa" textLength="72" lengthAdjust="spacingAndGlyphs">Header 3</text>
<text x="810" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="819" lengthAdjust="spacingAndGlyphs">├─────────────────────────────┼─────────────────────────────┼─────────────────────────────┤</text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="70" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Bold with Italic and Strike </text>
<text x="270" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="70" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Normal </text>
<text x="540" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="549" y="70" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Short </text>
<text x="810" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="87" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Short </text>
<text x="270" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="87" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Bold with Italic and Strike </text>
<text x="540" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="549" y="87" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Normal </text>
<text x="810" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="104" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Normal </text>
<text x="270" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="104" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Short </text>
<text x="540" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="549" y="104" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Bold with Italic and Strike </text>
<text x="810" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="819" lengthAdjust="spacingAndGlyphs">└─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -7,25 +7,25 @@
<text x="0" y="19" fill="#333333" textLength="405" lengthAdjust="spacingAndGlyphs">┌──────────────┬────────────┬───────────────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="63" lengthAdjust="spacingAndGlyphs">Emoji 😃</text>
<text x="126" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="117" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="36" fill="#89b4fa" textLength="90" lengthAdjust="spacingAndGlyphs">Asian 汉字</text>
<text x="243" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="234" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="36" fill="#89b4fa" textLength="108" lengthAdjust="spacingAndGlyphs">Mixed 🚀 Text</text>
<text x="378" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="405" lengthAdjust="spacingAndGlyphs">├──────────────┼────────────┼───────────────┤</text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="70" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Start 🌟 End </text>
<text x="126" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="135" y="70" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> 你好世界 </text>
<text x="243" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="252" y="70" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Rocket 🚀 Man </text>
<text x="9" y="70" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> Start 🌟 End</text>
<text x="117" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="70" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">你好世界 </text>
<text x="234" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="70" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs">Rocket 🚀 Man </text>
<text x="378" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="87" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Thumbs 👍 Up </text>
<text x="126" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="135" y="87" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> こんにちは </text>
<text x="243" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="252" y="87" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Fire 🔥 </text>
<text x="9" y="87" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> Thumbs 👍 Up</text>
<text x="117" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="87" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">こんにちは</text>
<text x="234" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="87" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs">Fire 🔥 </text>
<text x="378" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="405" lengthAdjust="spacingAndGlyphs">└──────────────┴────────────┴───────────────┘</text>
</g>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -7,40 +7,40 @@
<text x="0" y="19" fill="#333333" textLength="297" lengthAdjust="spacingAndGlyphs">┌─────────────┬───────┬─────────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="81" lengthAdjust="spacingAndGlyphs">Very Long</text>
<text x="126" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="117" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Short</text>
<text x="198" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="189" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="216" y="36" fill="#89b4fa" textLength="63" lengthAdjust="spacingAndGlyphs">Another</text>
<text x="288" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="53" fill="#89b4fa" textLength="99" lengthAdjust="spacingAndGlyphs">Bold Header</text>
<text x="126" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="198" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="117" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="189" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="216" y="53" fill="#89b4fa" textLength="36" lengthAdjust="spacingAndGlyphs">Long</text>
<text x="288" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#89b4fa" textLength="81" lengthAdjust="spacingAndGlyphs">That Will</text>
<text x="126" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="198" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="117" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="189" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="216" y="70" fill="#89b4fa" textLength="54" lengthAdjust="spacingAndGlyphs">Header</text>
<text x="288" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#89b4fa" textLength="36" lengthAdjust="spacingAndGlyphs">Wrap</text>
<text x="126" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="198" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="117" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="189" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="297" lengthAdjust="spacingAndGlyphs">├─────────────┼───────┼─────────┤</text>
<text x="0" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="121" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Data 1 </text>
<text x="126" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="135" y="121" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs"> Data </text>
<text x="198" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="207" y="121" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs"> Data 3 </text>
<text x="9" y="121" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> Data 1 </text>
<text x="117" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="121" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">Data </text>
<text x="189" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="216" y="121" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">Data 3 </text>
<text x="288" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="126" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="135" y="138" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs"> 2 </text>
<text x="198" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="117" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="138" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">2 </text>
<text x="189" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#333333" textLength="297" lengthAdjust="spacingAndGlyphs">└─────────────┴───────┴─────────┘</text>
</g>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -7,32 +7,32 @@
<text x="0" y="19" fill="#333333" textLength="414" lengthAdjust="spacingAndGlyphs">┌──────────────┬──────────────┬──────────────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="72" lengthAdjust="spacingAndGlyphs">Header 1</text>
<text x="135" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="126" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="153" y="36" fill="#89b4fa" textLength="72" lengthAdjust="spacingAndGlyphs">Header 2</text>
<text x="270" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="36" fill="#89b4fa" textLength="72" lengthAdjust="spacingAndGlyphs">Header 3</text>
<text x="405" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="414" lengthAdjust="spacingAndGlyphs">├──────────────┼──────────────┼──────────────┤</text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="70" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 1, Col 1 </text>
<text x="135" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="70" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 1, Col 2 </text>
<text x="270" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="70" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 1, Col 3 </text>
<text x="9" y="70" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Row 1, Col 1</text>
<text x="126" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="153" y="70" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Row 1, Col 2</text>
<text x="261" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="70" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs">Row 1, Col 3 </text>
<text x="405" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="87" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 2, Col 1 </text>
<text x="135" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="87" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 2, Col 2 </text>
<text x="270" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="87" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 2, Col 3 </text>
<text x="9" y="87" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Row 2, Col 1</text>
<text x="126" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="153" y="87" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Row 2, Col 2</text>
<text x="261" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="87" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs">Row 2, Col 3 </text>
<text x="405" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="104" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 3, Col 1 </text>
<text x="135" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="144" y="104" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 3, Col 2 </text>
<text x="270" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="104" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Row 3, Col 3 </text>
<text x="9" y="104" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> Row 3, Col 1</text>
<text x="126" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="153" y="104" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Row 3, Col 2</text>
<text x="261" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="104" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs">Row 3, Col 3 </text>
<text x="405" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="414" lengthAdjust="spacingAndGlyphs">└──────────────┴──────────────┴──────────────┘</text>
</g>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -7,394 +7,394 @@
<text x="0" y="19" fill="#333333" textLength="1404" lengthAdjust="spacingAndGlyphs">┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="243" lengthAdjust="spacingAndGlyphs">Comprehensive Architectural</text>
<text x="270" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="36" fill="#89b4fa" textLength="234" lengthAdjust="spacingAndGlyphs">Implementation Details for</text>
<text x="549" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="36" fill="#89b4fa" textLength="216" lengthAdjust="spacingAndGlyphs">Longitudinal Performance</text>
<text x="819" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="36" fill="#89b4fa" textLength="252" lengthAdjust="spacingAndGlyphs">Strategic Security Framework</text>
<text x="1098" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1116" y="36" fill="#89b4fa" textLength="27" lengthAdjust="spacingAndGlyphs">Key</text>
<text x="1152" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1170" y="36" fill="#89b4fa" textLength="54" lengthAdjust="spacingAndGlyphs">Status</text>
<text x="1233" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1251" y="36" fill="#89b4fa" textLength="63" lengthAdjust="spacingAndGlyphs">Version</text>
<text x="1323" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1341" y="36" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Owner</text>
<text x="1395" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="53" fill="#89b4fa" textLength="189" lengthAdjust="spacingAndGlyphs">Specification for the</text>
<text x="270" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="53" fill="#89b4fa" textLength="171" lengthAdjust="spacingAndGlyphs">the High-Throughput</text>
<text x="549" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="53" fill="#89b4fa" textLength="135" lengthAdjust="spacingAndGlyphs">Analysis Across</text>
<text x="819" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="53" fill="#89b4fa" textLength="252" lengthAdjust="spacingAndGlyphs">for Mitigating Sophisticated</text>
<text x="1098" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#89b4fa" textLength="234" lengthAdjust="spacingAndGlyphs">Distributed Infrastructure</text>
<text x="270" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="70" fill="#89b4fa" textLength="180" lengthAdjust="spacingAndGlyphs">Asynchronous Message</text>
<text x="549" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="70" fill="#89b4fa" textLength="180" lengthAdjust="spacingAndGlyphs">Multi-Regional Cloud</text>
<text x="819" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="70" fill="#89b4fa" textLength="180" lengthAdjust="spacingAndGlyphs">Cross-Site Scripting</text>
<text x="1098" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#89b4fa" textLength="45" lengthAdjust="spacingAndGlyphs">Layer</text>
<text x="270" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="87" fill="#89b4fa" textLength="216" lengthAdjust="spacingAndGlyphs">Processing Pipeline with</text>
<text x="549" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="87" fill="#89b4fa" textLength="171" lengthAdjust="spacingAndGlyphs">Deployment Clusters</text>
<text x="819" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="87" fill="#89b4fa" textLength="135" lengthAdjust="spacingAndGlyphs">Vulnerabilities</text>
<text x="1098" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="87" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="270" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="104" fill="#89b4fa" textLength="180" lengthAdjust="spacingAndGlyphs">Extended Scalability</text>
<text x="549" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="819" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1098" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="270" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="121" fill="#89b4fa" textLength="207" lengthAdjust="spacingAndGlyphs">Features and Redundancy</text>
<text x="549" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="819" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1098" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="270" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="138" fill="#89b4fa" textLength="81" lengthAdjust="spacingAndGlyphs">Protocols</text>
<text x="549" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="819" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1098" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#333333" textLength="1404" lengthAdjust="spacingAndGlyphs">├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤</text>
<text x="0" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="172" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> The primary architecture </text>
<text x="270" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="172" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> Each message is processed </text>
<text x="549" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="172" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Historical data indicates a </text>
<text x="819" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="172" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> A multi-layered defense </text>
<text x="1098" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1107" y="172" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> INF </text>
<text x="1152" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1161" y="172" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> Active </text>
<text x="1233" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1242" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs"> v2.4 </text>
<text x="1323" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1332" y="172" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs"> J. </text>
<text x="9" y="172" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> The primary architecture </text>
<text x="261" y="172" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="172" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Each message is processed </text>
<text x="540" y="172" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="172" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Historical data indicates a</text>
<text x="810" y="172" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="172" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">A multi-layered defense </text>
<text x="1089" y="172" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1116" y="172" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">INF</text>
<text x="1143" y="172" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1170" y="172" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">Active</text>
<text x="1224" y="172" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1251" y="172" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">v2.4 </text>
<text x="1314" y="172" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1341" y="172" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">J. </text>
<text x="1395" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="189" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> utilizes a decoupled </text>
<text x="270" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="189" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> through a series of </text>
<text x="549" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="189" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> significant reduction in </text>
<text x="819" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="189" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> strategy incorporates </text>
<text x="1098" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1332" y="189" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs"> Doe </text>
<text x="9" y="189" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> utilizes a decoupled </text>
<text x="261" y="189" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="189" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">through a series of </text>
<text x="540" y="189" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="189" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">significant reduction in </text>
<text x="810" y="189" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="189" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">strategy incorporates </text>
<text x="1089" y="189" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="189" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="189" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="189" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1341" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">Doe </text>
<text x="1395" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="206" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> microservices approach, </text>
<text x="270" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="206" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> specialized workers that </text>
<text x="549" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="206" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> tail latency when utilizing </text>
<text x="819" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="206" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> content security policies, </text>
<text x="1098" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="206" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> microservices approach, </text>
<text x="261" y="206" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="206" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">specialized workers that </text>
<text x="540" y="206" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="206" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">tail latency when utilizing</text>
<text x="810" y="206" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="206" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">content security policies, </text>
<text x="1089" y="206" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="206" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="206" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="206" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="223" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> leveraging container </text>
<text x="270" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="223" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> handle data transformation, </text>
<text x="549" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="223" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> edge computing nodes closer </text>
<text x="819" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="223" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> input sanitization </text>
<text x="1098" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="223" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> leveraging container </text>
<text x="261" y="223" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="223" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">handle data transformation, </text>
<text x="540" y="223" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="223" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">edge computing nodes closer</text>
<text x="810" y="223" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="223" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">input sanitization </text>
<text x="1089" y="223" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="223" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="223" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="223" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="240" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> orchestration for </text>
<text x="270" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="240" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> validation, and persistent </text>
<text x="549" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="240" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> to the geographic location </text>
<text x="819" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="240" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> libraries, and regular </text>
<text x="1098" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="240" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> orchestration for </text>
<text x="261" y="240" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="240" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">validation, and persistent </text>
<text x="540" y="240" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="240" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">to the geographic location </text>
<text x="810" y="240" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="240" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">libraries, and regular </text>
<text x="1089" y="240" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="240" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="240" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="240" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="257" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> scalability and fault </text>
<text x="270" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="257" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> storage using a persistent </text>
<text x="549" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="257" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> of the end-user base. </text>
<text x="819" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="257" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> automated penetration </text>
<text x="1098" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="257" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> scalability and fault </text>
<text x="261" y="257" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="257" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">storage using a persistent </text>
<text x="540" y="257" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="257" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">of the end-user base. </text>
<text x="810" y="257" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="257" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">automated penetration </text>
<text x="1089" y="257" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="257" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="257" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="257" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="274" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> tolerance in high-load </text>
<text x="270" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="274" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> queue. </text>
<text x="549" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="819" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="274" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> testing routines. </text>
<text x="1098" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="274" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> tolerance in high-load </text>
<text x="261" y="274" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="274" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">queue. </text>
<text x="540" y="274" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="274" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="274" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">testing routines. </text>
<text x="1089" y="274" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="274" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="274" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="274" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="291" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> scenarios. </text>
<text x="270" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="549" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="291" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Monitoring tools have </text>
<text x="819" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1098" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="291" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> scenarios. </text>
<text x="261" y="291" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="291" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="291" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Monitoring tools have </text>
<text x="810" y="291" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="291" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="291" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="291" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="291" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="270" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="308" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> The pipeline features </text>
<text x="549" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> captured a steady increase </text>
<text x="819" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="308" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> Developers are required to </text>
<text x="1098" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="308" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="308" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">The pipeline features </text>
<text x="540" y="308" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="308" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">captured a steady increase </text>
<text x="810" y="308" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="308" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Developers are required to </text>
<text x="1089" y="308" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="308" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="308" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="308" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="325" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> This layer provides the </text>
<text x="270" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="325" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> built-in retry mechanisms </text>
<text x="549" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="325" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> in throughput efficiency </text>
<text x="819" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="325" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> undergo mandatory security </text>
<text x="1098" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="325" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> This layer provides the </text>
<text x="261" y="325" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="325" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">built-in retry mechanisms </text>
<text x="540" y="325" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="325" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">in throughput efficiency </text>
<text x="810" y="325" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="325" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">undergo mandatory security </text>
<text x="1089" y="325" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="325" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="325" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="325" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="342" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> fundamental building blocks </text>
<text x="270" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="342" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> with exponential backoff to </text>
<text x="549" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="342" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> since the introduction of </text>
<text x="819" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="342" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> training focusing on the </text>
<text x="1098" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="342" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> fundamental building blocks</text>
<text x="261" y="342" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="342" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">with exponential backoff to </text>
<text x="540" y="342" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="342" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">since the introduction of </text>
<text x="810" y="342" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="342" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">training focusing on the </text>
<text x="1089" y="342" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="342" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="342" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="342" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="359" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> for service discovery, load </text>
<text x="270" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="359" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> ensure message delivery </text>
<text x="549" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="359" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> the vectorized query engine </text>
<text x="819" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="359" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> OWASP Top Ten to ensure that </text>
<text x="1098" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> for service discovery, load</text>
<text x="261" y="359" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">ensure message delivery </text>
<text x="540" y="359" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="359" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">the vectorized query engine</text>
<text x="810" y="359" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">OWASP Top Ten to ensure that</text>
<text x="1089" y="359" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="359" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="359" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="359" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="376" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> balancing, and </text>
<text x="270" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="376" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> integrity even during </text>
<text x="549" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="376" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> in the primary data </text>
<text x="819" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="376" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> security is integrated into </text>
<text x="1098" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="376" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> balancing, and </text>
<text x="261" y="376" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="376" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">integrity even during </text>
<text x="540" y="376" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="376" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">in the primary data </text>
<text x="810" y="376" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="376" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">security is integrated into </text>
<text x="1089" y="376" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="376" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="376" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="376" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="393" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> inter-service communication </text>
<text x="270" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="393" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> transient network or service </text>
<text x="549" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="393" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> warehouse. </text>
<text x="819" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="393" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> the initial design phase. </text>
<text x="1098" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="393" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> inter-service communication</text>
<text x="261" y="393" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="393" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">transient network or service</text>
<text x="540" y="393" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="393" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">warehouse. </text>
<text x="810" y="393" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="393" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">the initial design phase. </text>
<text x="1089" y="393" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="393" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="393" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="393" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="410" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> via highly efficient </text>
<text x="270" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="410" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> failures. </text>
<text x="549" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="819" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1098" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="410" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> via highly efficient </text>
<text x="261" y="410" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="410" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">failures. </text>
<text x="540" y="410" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="410" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="410" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="410" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="410" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="410" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="427" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> protocol buffers. </text>
<text x="270" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="549" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="427" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Resource utilization </text>
<text x="819" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="427" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> The implementation of a </text>
<text x="1098" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="427" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> protocol buffers. </text>
<text x="261" y="427" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="427" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="427" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Resource utilization </text>
<text x="810" y="427" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="427" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">The implementation of a </text>
<text x="1089" y="427" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="427" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="427" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="427" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="270" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="444" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> Horizontal autoscaling is </text>
<text x="549" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="444" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> metrics demonstrate that </text>
<text x="819" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="444" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> robust Identity and Access </text>
<text x="1098" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="261" y="444" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="444" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Horizontal autoscaling is </text>
<text x="540" y="444" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="444" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">metrics demonstrate that </text>
<text x="810" y="444" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="444" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">robust Identity and Access </text>
<text x="1089" y="444" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="444" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="444" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="444" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="461" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> Advanced telemetry and </text>
<text x="270" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="461" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> triggered automatically </text>
<text x="549" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="461" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> the transition to </text>
<text x="819" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="461" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> Management system ensures </text>
<text x="1098" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="461" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> Advanced telemetry and </text>
<text x="261" y="461" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="461" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">triggered automatically </text>
<text x="540" y="461" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="461" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">the transition to </text>
<text x="810" y="461" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="461" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Management system ensures </text>
<text x="1089" y="461" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="461" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="461" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="461" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="478" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> logging integrations allow </text>
<text x="270" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="478" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> based on the depth of the </text>
<text x="549" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="478" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> serverless compute for </text>
<text x="819" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="478" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> that the principle of least </text>
<text x="1098" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="478" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> logging integrations allow </text>
<text x="261" y="478" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="478" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">based on the depth of the </text>
<text x="540" y="478" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="478" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">serverless compute for </text>
<text x="810" y="478" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="478" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">that the principle of least </text>
<text x="1089" y="478" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="478" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="478" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="478" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="495" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> for real-time monitoring of </text>
<text x="270" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="495" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> processing queue, ensuring </text>
<text x="549" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="495" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> intermittent tasks has </text>
<text x="819" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="495" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> privilege is strictly </text>
<text x="1098" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="495" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> for real-time monitoring of</text>
<text x="261" y="495" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="495" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">processing queue, ensuring </text>
<text x="540" y="495" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="495" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">intermittent tasks has </text>
<text x="810" y="495" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="495" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">privilege is strictly </text>
<text x="1089" y="495" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="495" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="495" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="495" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="512" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> system health and rapid </text>
<text x="270" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="512" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> consistent performance </text>
<text x="549" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="512" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> resulted in a thirty </text>
<text x="819" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="512" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> enforced across all </text>
<text x="1098" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="512" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> system health and rapid </text>
<text x="261" y="512" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="512" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">consistent performance </text>
<text x="540" y="512" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="512" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">resulted in a thirty </text>
<text x="810" y="512" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="512" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">enforced across all </text>
<text x="1089" y="512" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="512" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="512" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="512" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="529" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> identification of </text>
<text x="270" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="529" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> during unexpected traffic </text>
<text x="549" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="558" y="529" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> percent cost optimization. </text>
<text x="819" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="828" y="529" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> environments. </text>
<text x="1098" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="529" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> identification of </text>
<text x="261" y="529" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="529" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">during unexpected traffic </text>
<text x="540" y="529" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="567" y="529" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">percent cost optimization. </text>
<text x="810" y="529" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="837" y="529" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">environments. </text>
<text x="1089" y="529" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="529" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="529" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="529" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="546" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> bottlenecks within the </text>
<text x="270" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="546" fill="#ffffff" textLength="270" lengthAdjust="spacingAndGlyphs"> spikes. </text>
<text x="549" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="819" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1098" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="546" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> bottlenecks within the </text>
<text x="261" y="546" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="288" y="546" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">spikes. </text>
<text x="540" y="546" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="546" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="546" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="546" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="546" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="546" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="563" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs"> service mesh. </text>
<text x="270" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="549" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="819" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1098" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1152" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1233" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="1323" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="563" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs"> service mesh. </text>
<text x="261" y="563" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="540" y="563" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="810" y="563" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1089" y="563" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1143" y="563" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1224" y="563" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1314" y="563" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="1395" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="1404" lengthAdjust="spacingAndGlyphs">└─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘</text>
</g>

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

@@ -7,56 +7,56 @@
<text x="0" y="19" fill="#333333" textLength="639" lengthAdjust="spacingAndGlyphs">┌───────────────┬───────────────┬──────────────────┬──────────────────┐</text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="36" fill="#89b4fa" textLength="81" lengthAdjust="spacingAndGlyphs">Very Long</text>
<text x="144" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="135" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="162" y="36" fill="#89b4fa" textLength="81" lengthAdjust="spacingAndGlyphs">Very Long</text>
<text x="288" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="306" y="36" fill="#89b4fa" textLength="144" lengthAdjust="spacingAndGlyphs">Very Long Column</text>
<text x="459" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="450" y="36" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="477" y="36" fill="#89b4fa" textLength="144" lengthAdjust="spacingAndGlyphs">Very Long Column</text>
<text x="630" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="53" fill="#89b4fa" textLength="117" lengthAdjust="spacingAndGlyphs">Column Header</text>
<text x="144" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="135" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="162" y="53" fill="#89b4fa" textLength="117" lengthAdjust="spacingAndGlyphs">Column Header</text>
<text x="288" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="306" y="53" fill="#89b4fa" textLength="108" lengthAdjust="spacingAndGlyphs">Header Three</text>
<text x="459" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="450" y="53" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="477" y="53" fill="#89b4fa" textLength="99" lengthAdjust="spacingAndGlyphs">Header Four</text>
<text x="630" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#89b4fa" textLength="27" lengthAdjust="spacingAndGlyphs">One</text>
<text x="144" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="135" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="162" y="70" fill="#89b4fa" textLength="27" lengthAdjust="spacingAndGlyphs">Two</text>
<text x="288" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="459" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="279" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="450" y="70" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="630" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="639" lengthAdjust="spacingAndGlyphs">├───────────────┼───────────────┼──────────────────┼──────────────────┤</text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="104" fill="#ffffff" textLength="135" lengthAdjust="spacingAndGlyphs"> Data 1.1 </text>
<text x="144" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="153" y="104" fill="#ffffff" textLength="135" lengthAdjust="spacingAndGlyphs"> Data 1.2 </text>
<text x="288" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="297" y="104" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs"> Data 1.3 </text>
<text x="459" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="468" y="104" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs"> Data 1.4 </text>
<text x="9" y="104" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Data 1.1 </text>
<text x="135" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="162" y="104" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs">Data 1.2 </text>
<text x="279" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="306" y="104" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Data 1.3 </text>
<text x="450" y="104" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="477" y="104" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs">Data 1.4 </text>
<text x="630" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="121" fill="#ffffff" textLength="135" lengthAdjust="spacingAndGlyphs"> Data 2.1 </text>
<text x="144" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="153" y="121" fill="#ffffff" textLength="135" lengthAdjust="spacingAndGlyphs"> Data 2.2 </text>
<text x="288" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="297" y="121" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs"> Data 2.3 </text>
<text x="459" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="468" y="121" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs"> Data 2.4 </text>
<text x="9" y="121" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Data 2.1 </text>
<text x="135" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="162" y="121" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs">Data 2.2 </text>
<text x="279" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="306" y="121" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Data 2.3 </text>
<text x="450" y="121" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="477" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs">Data 2.4 </text>
<text x="630" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="138" fill="#ffffff" textLength="135" lengthAdjust="spacingAndGlyphs"> Data 3.1 </text>
<text x="144" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="153" y="138" fill="#ffffff" textLength="135" lengthAdjust="spacingAndGlyphs"> Data 3.2 </text>
<text x="288" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="297" y="138" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs"> Data 3.3 </text>
<text x="459" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="468" y="138" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs"> Data 3.4 </text>
<text x="9" y="138" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs"> Data 3.1 </text>
<text x="135" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="162" y="138" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs">Data 3.2 </text>
<text x="279" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="306" y="138" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Data 3.3 </text>
<text x="450" y="138" fill="#333333" textLength="27" lengthAdjust="spacingAndGlyphs"></text>
<text x="477" y="138" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs">Data 3.4 </text>
<text x="630" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#333333" textLength="639" lengthAdjust="spacingAndGlyphs">└───────────────┴───────────────┴──────────────────┴──────────────────┘</text>
</g>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Some files were not shown because too many files have changed in this diff Show More