mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1b110a618 | |||
| a380b4219c | |||
| 31c6fef1e8 | |||
| d8e9db3761 | |||
| 43846f4e3b | |||
| 2a3c879782 | |||
| 958cc45937 | |||
| 9c667cf7ba | |||
| c593a29647 | |||
| cebe386d79 | |||
| 1c207e2f82 | |||
| ee87c98f43 | |||
| 75b5eeeb12 | |||
| 0fa9a54088 | |||
| 603e66b2ea | |||
| dc8fc75ac0 | |||
| 2b58605227 |
@@ -62,6 +62,7 @@ available combinations.
|
|||||||
| Start reverse search through history. | `Ctrl + R` |
|
| Start reverse search through history. | `Ctrl + R` |
|
||||||
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
|
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
|
||||||
| Accept a suggestion while reverse searching. | `Tab` |
|
| Accept a suggestion while reverse searching. | `Tab` |
|
||||||
|
| Browse and rewind previous interactions. | `Double Esc` |
|
||||||
|
|
||||||
#### Navigation
|
#### Navigation
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -113,14 +113,16 @@ they appear in the UI.
|
|||||||
|
|
||||||
### Experimental
|
### Experimental
|
||||||
|
|
||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||||
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
|
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||||
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
|
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||||
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
|
|
||||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
### Skills
|
||||||
| Enable CLI Help Agent | `experimental.cliHelpAgentSettings.enabled` | Enable the CLI Help Agent. | `true` |
|
|
||||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
| UI Label | Setting | Description | Default |
|
||||||
|
| ------------------- | ---------------- | -------------------- | ------- |
|
||||||
|
| Enable Agent Skills | `skills.enabled` | Enable Agent Skills. | `true` |
|
||||||
|
|
||||||
### HooksConfig
|
### HooksConfig
|
||||||
|
|
||||||
|
|||||||
@@ -851,7 +851,7 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Requires restart:** Yes
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
- **`experimental.skills`** (boolean):
|
- **`experimental.skills`** (boolean):
|
||||||
- **Description:** Enable Agent Skills (experimental).
|
- **Description:** [Deprecated] Enable Agent Skills (experimental).
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
- **Requires restart:** Yes
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
@@ -899,6 +899,11 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
|
|
||||||
#### `skills`
|
#### `skills`
|
||||||
|
|
||||||
|
- **`skills.enabled`** (boolean):
|
||||||
|
- **Description:** Enable Agent Skills.
|
||||||
|
- **Default:** `true`
|
||||||
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
- **`skills.disabled`** (array):
|
- **`skills.disabled`** (array):
|
||||||
- **Description:** List of disabled skills.
|
- **Description:** List of disabled skills.
|
||||||
- **Default:** `[]`
|
- **Default:** `[]`
|
||||||
@@ -909,7 +914,7 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **`hooksConfig.enabled`** (boolean):
|
- **`hooksConfig.enabled`** (boolean):
|
||||||
- **Description:** Canonical toggle for the hooks system. When disabled, no
|
- **Description:** Canonical toggle for the hooks system. When disabled, no
|
||||||
hooks will be executed.
|
hooks will be executed.
|
||||||
- **Default:** `false`
|
- **Default:** `true`
|
||||||
|
|
||||||
- **`hooksConfig.disabled`** (array):
|
- **`hooksConfig.disabled`** (array):
|
||||||
- **Description:** List of hook names (commands) that should be disabled.
|
- **Description:** List of hook names (commands) that should be disabled.
|
||||||
|
|||||||
+211
-7
@@ -106,13 +106,217 @@ If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON.
|
|||||||
|
|
||||||
### `hookSpecificOutput` Reference
|
### `hookSpecificOutput` Reference
|
||||||
|
|
||||||
| Field | Supported Events | Description |
|
### Matchers and tool names
|
||||||
| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- |
|
|
||||||
| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. |
|
For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
|
||||||
| `llm_request` | `BeforeModel` | A `Partial<LLMRequest>` to override parameters of the outgoing call. |
|
compared against the name of the tool being executed.
|
||||||
| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. |
|
|
||||||
| `llm_response` | `AfterModel` | A `Partial<LLMResponse>` to modify the model's response before the agent sees it. |
|
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
|
||||||
| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. |
|
`run_shell_command`). See the [Tools Reference](/docs/tools) for a full list
|
||||||
|
of available tool names.
|
||||||
|
- **MCP Tools**: Tools from MCP servers follow the naming pattern
|
||||||
|
`mcp__<server_name>__<tool_name>`.
|
||||||
|
- **Regex Support**: Matchers support regular expressions (e.g.,
|
||||||
|
`matcher: "read_.*"` matches all file reading tools).
|
||||||
|
|
||||||
|
### `BeforeTool`
|
||||||
|
|
||||||
|
Fires before a tool is invoked. Used for argument validation, security checks,
|
||||||
|
and parameter rewriting.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `tool_name`: (`string`) The name of the tool being called.
|
||||||
|
- `tool_input`: (`object`) The raw arguments generated by the model.
|
||||||
|
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
|
||||||
|
executing.
|
||||||
|
- `reason`: Required if denied. This text is sent **to the agent** as a tool
|
||||||
|
error, allowing it to respond or retry.
|
||||||
|
- `hookSpecificOutput.tool_input`: An object that **merges with and
|
||||||
|
overrides** the model's arguments before execution.
|
||||||
|
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||||
|
- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the
|
||||||
|
`reason` sent to the agent. **The turn continues.**
|
||||||
|
|
||||||
|
### `AfterTool`
|
||||||
|
|
||||||
|
Fires after a tool executes. Used for result auditing, context injection, or
|
||||||
|
hiding sensitive output from the agent.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `tool_name`: (`string`)
|
||||||
|
- `tool_input`: (`object`) The original arguments.
|
||||||
|
- `tool_response`: (`object`) The result containing `llmContent`,
|
||||||
|
`returnDisplay`, and optional `error`.
|
||||||
|
- `mcp_context`: (`object`)
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
|
||||||
|
- `reason`: Required if denied. This text **replaces** the tool result sent
|
||||||
|
back to the model.
|
||||||
|
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
|
||||||
|
tool result for the agent.
|
||||||
|
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||||
|
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
|
||||||
|
replacement content sent to the agent. **The turn continues.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent hooks
|
||||||
|
|
||||||
|
### `BeforeAgent`
|
||||||
|
|
||||||
|
Fires after a user submits a prompt, but before the agent begins planning. Used
|
||||||
|
for prompt validation or injecting dynamic context.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `prompt`: (`string`) The original text submitted by the user.
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
|
||||||
|
prompt for this turn only.
|
||||||
|
- `decision`: Set to `"deny"` to block the turn and **discard the user's
|
||||||
|
message** (it will not appear in history).
|
||||||
|
- `continue`: Set to `false` to block the turn but **save the message to
|
||||||
|
history**.
|
||||||
|
- `reason`: Required if denied or stopped.
|
||||||
|
- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from
|
||||||
|
context. Same as `decision: "deny"`.
|
||||||
|
|
||||||
|
### `AfterAgent`
|
||||||
|
|
||||||
|
Fires once per turn after the model generates its final response. Primary use
|
||||||
|
case is response validation and automatic retries.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `prompt`: (`string`) The user's original request.
|
||||||
|
- `prompt_response`: (`string`) The final text generated by the agent.
|
||||||
|
- `stop_hook_active`: (`boolean`) Indicates if this hook is already running as
|
||||||
|
part of a retry sequence.
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `decision`: Set to `"deny"` to **reject the response** and force a retry.
|
||||||
|
- `reason`: Required if denied. This text is sent **to the agent as a new
|
||||||
|
prompt** to request a correction.
|
||||||
|
- `continue`: Set to `false` to **stop the session** without retrying.
|
||||||
|
- `clearContext`: If `true`, clears conversation history (LLM memory) while
|
||||||
|
preserving UI display.
|
||||||
|
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
|
||||||
|
turn using `stderr` as the feedback prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model hooks
|
||||||
|
|
||||||
|
### `BeforeModel`
|
||||||
|
|
||||||
|
Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic
|
||||||
|
request format.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `llm_request`: (`object`) Contains `model`, `messages`, and `config`
|
||||||
|
(generation params).
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `hookSpecificOutput.llm_request`: An object that **overrides** parts of the
|
||||||
|
outgoing request (e.g., changing models or temperature).
|
||||||
|
- `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If
|
||||||
|
provided, the CLI skips the LLM call entirely and uses this as the response.
|
||||||
|
- `decision`: Set to `"deny"` to block the request and abort the turn.
|
||||||
|
- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses
|
||||||
|
`stderr` as the error message.
|
||||||
|
|
||||||
|
### `BeforeToolSelection`
|
||||||
|
|
||||||
|
Fires before the LLM decides which tools to call. Used to filter the available
|
||||||
|
toolset or force specific tool modes.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `llm_request`: (`object`) Same format as `BeforeModel`.
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`)
|
||||||
|
- `"NONE"`: Disables all tools (Wins over other hooks).
|
||||||
|
- `"ANY"`: Forces at least one tool call.
|
||||||
|
- `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist
|
||||||
|
of tool names.
|
||||||
|
- **Union Strategy**: Multiple hooks' whitelists are **combined**.
|
||||||
|
- **Limitations**: Does **not** support `decision`, `continue`, or
|
||||||
|
`systemMessage`.
|
||||||
|
|
||||||
|
### `AfterModel`
|
||||||
|
|
||||||
|
Fires immediately after an LLM response chunk is received. Used for real-time
|
||||||
|
redaction or PII filtering.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `llm_request`: (`object`) The original request.
|
||||||
|
- `llm_response`: (`object`) The model's response (or a single chunk during
|
||||||
|
streaming).
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `hookSpecificOutput.llm_response`: An object that **replaces** the model's
|
||||||
|
response chunk.
|
||||||
|
- `decision`: Set to `"deny"` to discard the response chunk and block the
|
||||||
|
turn.
|
||||||
|
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||||
|
- **Note on Streaming**: Fired for **every chunk** generated by the model.
|
||||||
|
Modifying the response only affects the current chunk.
|
||||||
|
- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's
|
||||||
|
output. Uses `stderr` as the error message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lifecycle & system hooks
|
||||||
|
|
||||||
|
### `SessionStart`
|
||||||
|
|
||||||
|
Fires on application startup, resuming a session, or after a `/clear` command.
|
||||||
|
Used for loading initial context.
|
||||||
|
|
||||||
|
- **Input fields**:
|
||||||
|
- `source`: (`"startup" | "resume" | "clear"`)
|
||||||
|
- **Relevant output fields**:
|
||||||
|
- `hookSpecificOutput.additionalContext`: (`string`)
|
||||||
|
- **Interactive**: Injected as the first turn in history.
|
||||||
|
- **Non-interactive**: Prepended to the user's prompt.
|
||||||
|
- `systemMessage`: Shown at the start of the session.
|
||||||
|
- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup
|
||||||
|
is never blocked.
|
||||||
|
|
||||||
|
### `SessionEnd`
|
||||||
|
|
||||||
|
Fires when the CLI exits or a session is cleared. Used for cleanup or final
|
||||||
|
telemetry.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`)
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `systemMessage`: Displayed to the user during shutdown.
|
||||||
|
- **Best Effort**: The CLI **will not wait** for this hook to complete and
|
||||||
|
ignores all flow-control fields (`continue`, `decision`).
|
||||||
|
|
||||||
|
### `Notification`
|
||||||
|
|
||||||
|
Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for
|
||||||
|
external logging or cross-platform alerts.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `notification_type`: (`"ToolPermission"`)
|
||||||
|
- `message`: Summary of the alert.
|
||||||
|
- `details`: JSON object with alert-specific metadata (e.g., tool name, file
|
||||||
|
path).
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `systemMessage`: Displayed alongside the system alert.
|
||||||
|
- **Observability Only**: This hook **cannot** block alerts or grant permissions
|
||||||
|
automatically. Flow-control fields are ignored.
|
||||||
|
|
||||||
|
### `PreCompress`
|
||||||
|
|
||||||
|
Fires before the CLI summarizes history to save tokens. Used for logging or
|
||||||
|
state saving.
|
||||||
|
|
||||||
|
- **Input Fields**:
|
||||||
|
- `trigger`: (`"auto" | "manual"`)
|
||||||
|
- **Relevant Output Fields**:
|
||||||
|
- `systemMessage`: Displayed to the user before compression.
|
||||||
|
- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the
|
||||||
|
compression process. Flow-control fields are ignored.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,84 @@ describe('Hooks Agent Flow', () => {
|
|||||||
// The fake response contains "Hello World"
|
// The fake response contains "Hello World"
|
||||||
expect(afterAgentLog?.hookCall.stdout).toContain('Hello World');
|
expect(afterAgentLog?.hookCall.stdout).toContain('Hello World');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should process clearContext in AfterAgent hook output', async () => {
|
||||||
|
await rig.setup('should process clearContext in AfterAgent hook output', {
|
||||||
|
fakeResponsesPath: join(
|
||||||
|
import.meta.dirname,
|
||||||
|
'hooks-system.after-agent.responses',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// BeforeModel hook to track message counts across LLM calls
|
||||||
|
const messageCountFile = join(rig.testDir!, 'message-counts.json');
|
||||||
|
const beforeModelScript = `
|
||||||
|
const fs = require('fs');
|
||||||
|
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
|
||||||
|
const messageCount = input.llm_request?.contents?.length || 0;
|
||||||
|
let counts = [];
|
||||||
|
try { counts = JSON.parse(fs.readFileSync('${messageCountFile}', 'utf-8')); } catch (e) {}
|
||||||
|
counts.push(messageCount);
|
||||||
|
fs.writeFileSync('${messageCountFile}', JSON.stringify(counts));
|
||||||
|
console.log(JSON.stringify({ decision: 'allow' }));
|
||||||
|
`;
|
||||||
|
const beforeModelScriptPath = join(
|
||||||
|
rig.testDir!,
|
||||||
|
'before_model_counter.cjs',
|
||||||
|
);
|
||||||
|
writeFileSync(beforeModelScriptPath, beforeModelScript);
|
||||||
|
|
||||||
|
await rig.setup('should process clearContext in AfterAgent hook output', {
|
||||||
|
settings: {
|
||||||
|
hooks: {
|
||||||
|
enabled: true,
|
||||||
|
BeforeModel: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: `node "${beforeModelScriptPath}"`,
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
AfterAgent: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: `node -e "console.log(JSON.stringify({decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: {hookEventName: 'AfterAgent', clearContext: true}}))"`,
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await rig.run({ args: 'Hello test' });
|
||||||
|
|
||||||
|
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
|
||||||
|
expect(hookTelemetryFound).toBeTruthy();
|
||||||
|
|
||||||
|
const hookLogs = rig.readHookLogs();
|
||||||
|
const afterAgentLog = hookLogs.find(
|
||||||
|
(log) => log.hookCall.hook_event_name === 'AfterAgent',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(afterAgentLog).toBeDefined();
|
||||||
|
expect(afterAgentLog?.hookCall.stdout).toContain('clearContext');
|
||||||
|
expect(afterAgentLog?.hookCall.stdout).toContain('true');
|
||||||
|
expect(result).toContain('Security policy triggered');
|
||||||
|
|
||||||
|
// Verify context was cleared: second call should not have more messages than first
|
||||||
|
const countsRaw = rig.readFile('message-counts.json');
|
||||||
|
const counts = JSON.parse(countsRaw) as number[];
|
||||||
|
expect(counts.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(counts[1]).toBeLessThanOrEqual(counts[0]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Multi-step Loops', () => {
|
describe('Multi-step Loops', () => {
|
||||||
|
|||||||
Generated
+22
-33
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@google/gemini-cli",
|
"name": "@google/gemini-cli",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@google/gemini-cli",
|
"name": "@google/gemini-cli",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
@@ -2474,7 +2474,6 @@
|
|||||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-token": "^6.0.0",
|
"@octokit/auth-token": "^6.0.0",
|
||||||
"@octokit/graphql": "^9.0.2",
|
"@octokit/graphql": "^9.0.2",
|
||||||
@@ -2655,7 +2654,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
@@ -2689,7 +2687,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
},
|
},
|
||||||
@@ -3058,7 +3055,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
@@ -3092,7 +3088,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/resources": "2.0.1"
|
"@opentelemetry/resources": "2.0.1"
|
||||||
@@ -3145,7 +3140,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/resources": "2.0.1",
|
"@opentelemetry/resources": "2.0.1",
|
||||||
@@ -4358,7 +4352,6 @@
|
|||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -4636,7 +4629,6 @@
|
|||||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.35.0",
|
"@typescript-eslint/scope-manager": "8.35.0",
|
||||||
"@typescript-eslint/types": "8.35.0",
|
"@typescript-eslint/types": "8.35.0",
|
||||||
@@ -5641,7 +5633,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -6086,7 +6077,8 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/array-includes": {
|
"node_modules/array-includes": {
|
||||||
"version": "3.1.9",
|
"version": "3.1.9",
|
||||||
@@ -7370,6 +7362,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "5.2.1"
|
"safe-buffer": "5.2.1"
|
||||||
},
|
},
|
||||||
@@ -8689,7 +8682,6 @@
|
|||||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -9292,6 +9284,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -9301,6 +9294,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
@@ -9310,6 +9304,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -9563,6 +9558,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "~2.0.0",
|
||||||
@@ -9581,6 +9577,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
@@ -9589,13 +9586,15 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/finalhandler/node_modules/statuses": {
|
"node_modules/finalhandler/node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -10878,7 +10877,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
|
||||||
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
|
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||||
"ansi-escapes": "^7.0.0",
|
"ansi-escapes": "^7.0.0",
|
||||||
@@ -14063,7 +14061,8 @@
|
|||||||
"version": "0.1.12",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/path-type": {
|
"node_modules/path-type": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@@ -14640,7 +14639,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -14651,7 +14649,6 @@
|
|||||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shell-quote": "^1.6.1",
|
"shell-quote": "^1.6.1",
|
||||||
"ws": "^7"
|
"ws": "^7"
|
||||||
@@ -16911,7 +16908,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -17135,8 +17131,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD",
|
"license": "0BSD"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.20.3",
|
"version": "4.20.3",
|
||||||
@@ -17144,7 +17139,6 @@
|
|||||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.25.0",
|
"esbuild": "~0.25.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -17328,7 +17322,6 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -17491,6 +17484,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
@@ -17545,7 +17539,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -17659,7 +17652,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -17672,7 +17664,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
@@ -18377,7 +18368,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -18393,7 +18383,7 @@
|
|||||||
},
|
},
|
||||||
"packages/a2a-server": {
|
"packages/a2a-server": {
|
||||||
"name": "@google/gemini-cli-a2a-server",
|
"name": "@google/gemini-cli-a2a-server",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@a2a-js/sdk": "^0.3.8",
|
"@a2a-js/sdk": "^0.3.8",
|
||||||
"@google-cloud/storage": "^7.16.0",
|
"@google-cloud/storage": "^7.16.0",
|
||||||
@@ -18703,7 +18693,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@google/gemini-cli",
|
"name": "@google/gemini-cli",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.12.0",
|
"@agentclientprotocol/sdk": "^0.12.0",
|
||||||
@@ -18807,7 +18797,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@google/gemini-cli-core",
|
"name": "@google/gemini-cli-core",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@a2a-js/sdk": "^0.3.8",
|
"@a2a-js/sdk": "^0.3.8",
|
||||||
@@ -18944,7 +18934,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -18967,7 +18956,7 @@
|
|||||||
},
|
},
|
||||||
"packages/test-utils": {
|
"packages/test-utils": {
|
||||||
"name": "@google/gemini-cli-test-utils",
|
"name": "@google/gemini-cli-test-utils",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/gemini-cli-core": "file:../core",
|
"@google/gemini-cli-core": "file:../core",
|
||||||
@@ -18984,7 +18973,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode-ide-companion": {
|
"packages/vscode-ide-companion": {
|
||||||
"name": "gemini-cli-vscode-ide-companion",
|
"name": "gemini-cli-vscode-ide-companion",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"license": "LICENSE",
|
"license": "LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@google/gemini-cli",
|
"name": "@google/gemini-cli",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
|
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@google/gemini-cli-a2a-server",
|
"name": "@google/gemini-cli-a2a-server",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"description": "Gemini CLI A2A Server",
|
"description": "Gemini CLI A2A Server",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@google/gemini-cli",
|
"name": "@google/gemini-cli",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"description": "Gemini CLI",
|
"description": "Gemini CLI",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
|
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.12.0",
|
"@agentclientprotocol/sdk": "^0.12.0",
|
||||||
|
|||||||
@@ -511,8 +511,5 @@ describe('migrate command', () => {
|
|||||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||||
);
|
);
|
||||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
|
||||||
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -244,9 +244,6 @@ export async function handleMigrateFromClaude() {
|
|||||||
debugLogger.log(
|
debugLogger.log(
|
||||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||||
);
|
);
|
||||||
debugLogger.log(
|
|
||||||
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
|
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
type HookEventName,
|
type HookEventName,
|
||||||
type OutputFormat,
|
type OutputFormat,
|
||||||
GEMINI_MODEL_ALIAS_AUTO,
|
GEMINI_MODEL_ALIAS_AUTO,
|
||||||
|
coreEvents,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
type Settings,
|
type Settings,
|
||||||
@@ -47,7 +48,6 @@ import {
|
|||||||
|
|
||||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||||
import { resolvePath } from '../utils/resolvePath.js';
|
import { resolvePath } from '../utils/resolvePath.js';
|
||||||
import { appEvents } from '../utils/events.js';
|
|
||||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||||
|
|
||||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
@@ -450,7 +450,7 @@ export async function loadCliConfig(
|
|||||||
requestSetting: promptForSetting,
|
requestSetting: promptForSetting,
|
||||||
workspaceDir: cwd,
|
workspaceDir: cwd,
|
||||||
enabledExtensionOverrides: argv.extensions,
|
enabledExtensionOverrides: argv.extensions,
|
||||||
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
|
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||||
clientVersion: await getVersion(),
|
clientVersion: await getVersion(),
|
||||||
});
|
});
|
||||||
await extensionManager.loadExtensions();
|
await extensionManager.loadExtensions();
|
||||||
@@ -746,7 +746,7 @@ export async function loadCliConfig(
|
|||||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||||
eventEmitter: appEvents,
|
eventEmitter: coreEvents,
|
||||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||||
output: {
|
output: {
|
||||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||||
@@ -763,7 +763,7 @@ export async function loadCliConfig(
|
|||||||
// TODO: loading of hooks based on workspace trust
|
// TODO: loading of hooks based on workspace trust
|
||||||
enableHooks:
|
enableHooks:
|
||||||
(settings.tools?.enableHooks ?? true) &&
|
(settings.tools?.enableHooks ?? true) &&
|
||||||
(settings.hooksConfig?.enabled ?? false),
|
(settings.hooksConfig?.enabled ?? true),
|
||||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||||
hooks: settings.hooks || {},
|
hooks: settings.hooks || {},
|
||||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||||
|
|||||||
@@ -872,6 +872,7 @@ describe('extension tests', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||||
|
settings.hooksConfig.enabled = false;
|
||||||
|
|
||||||
extensionManager = new ExtensionManager({
|
extensionManager = new ExtensionManager({
|
||||||
workspaceDir: tempWorkspaceDir,
|
workspaceDir: tempWorkspaceDir,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export enum Command {
|
|||||||
REVERSE_SEARCH = 'history.search.start',
|
REVERSE_SEARCH = 'history.search.start',
|
||||||
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
||||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
||||||
|
REWIND = 'history.rewind',
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
NAVIGATION_UP = 'nav.up',
|
NAVIGATION_UP = 'nav.up',
|
||||||
@@ -183,6 +184,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
|||||||
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
|
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
|
||||||
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
|
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
|
||||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||||
|
[Command.REWIND]: [{ key: 'double escape' }],
|
||||||
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
|
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
|
||||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
||||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||||
@@ -313,6 +315,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
|||||||
Command.REVERSE_SEARCH,
|
Command.REVERSE_SEARCH,
|
||||||
Command.SUBMIT_REVERSE_SEARCH,
|
Command.SUBMIT_REVERSE_SEARCH,
|
||||||
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
||||||
|
Command.REWIND,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -409,6 +412,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
|||||||
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
||||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
||||||
'Accept a suggestion while reverse searching.',
|
'Accept a suggestion while reverse searching.',
|
||||||
|
[Command.REWIND]: 'Browse and rewind previous interactions.',
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
||||||
|
|||||||
@@ -1454,12 +1454,12 @@ const SETTINGS_SCHEMA = {
|
|||||||
},
|
},
|
||||||
skills: {
|
skills: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Agent Skills',
|
label: 'Agent Skills (Deprecated)',
|
||||||
category: 'Experimental',
|
category: 'Experimental',
|
||||||
requiresRestart: true,
|
requiresRestart: true,
|
||||||
default: false,
|
default: false,
|
||||||
description: 'Enable Agent Skills (experimental).',
|
description: '[Deprecated] Enable Agent Skills (experimental).',
|
||||||
showInDialog: true,
|
showInDialog: false,
|
||||||
},
|
},
|
||||||
codebaseInvestigatorSettings: {
|
codebaseInvestigatorSettings: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1615,7 +1615,6 @@ const SETTINGS_SCHEMA = {
|
|||||||
default: true,
|
default: true,
|
||||||
description: 'Enable Agent Skills.',
|
description: 'Enable Agent Skills.',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
ignoreInDocs: true,
|
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -1646,7 +1645,7 @@ const SETTINGS_SCHEMA = {
|
|||||||
label: 'Enable Hooks',
|
label: 'Enable Hooks',
|
||||||
category: 'Advanced',
|
category: 'Advanced',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: false,
|
default: true,
|
||||||
description:
|
description:
|
||||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
|
|||||||
@@ -1085,6 +1085,8 @@ describe('gemini.tsx main function exit codes', () => {
|
|||||||
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any);
|
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any);
|
||||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||||
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
|
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
|
||||||
|
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
|
||||||
|
isInteractive: vi.fn().mockReturnValue(true),
|
||||||
} as unknown as Config);
|
} as unknown as Config);
|
||||||
vi.mocked(loadSettings).mockReturnValue(
|
vi.mocked(loadSettings).mockReturnValue(
|
||||||
createMockSettings({
|
createMockSettings({
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ export async function main() {
|
|||||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||||
// the sandbox because the sandbox will interfere with the Oauth2 web
|
// the sandbox because the sandbox will interfere with the Oauth2 web
|
||||||
// redirect.
|
// redirect.
|
||||||
|
let initialAuthFailed = false;
|
||||||
if (
|
if (
|
||||||
settings.merged.security.auth.selectedType &&
|
settings.merged.security.auth.selectedType &&
|
||||||
!settings.merged.security.auth.useExternal
|
!settings.merged.security.auth.useExternal
|
||||||
@@ -400,8 +401,7 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debugLogger.error('Error authenticating:', err);
|
debugLogger.error('Error authenticating:', err);
|
||||||
await runExitCleanup();
|
initialAuthFailed = true;
|
||||||
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +427,10 @@ export async function main() {
|
|||||||
// another way to decouple refreshAuth from requiring a config.
|
// another way to decouple refreshAuth from requiring a config.
|
||||||
|
|
||||||
if (sandboxConfig) {
|
if (sandboxConfig) {
|
||||||
|
if (initialAuthFailed) {
|
||||||
|
await runExitCleanup();
|
||||||
|
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
||||||
|
}
|
||||||
let stdinData = '';
|
let stdinData = '';
|
||||||
if (!process.stdin.isTTY) {
|
if (!process.stdin.isTTY) {
|
||||||
stdinData = await readStdin();
|
stdinData = await readStdin();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
|||||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||||
|
import { rewindCommand } from '../ui/commands/rewindCommand.js';
|
||||||
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||||
import { initCommand } from '../ui/commands/initCommand.js';
|
import { initCommand } from '../ui/commands/initCommand.js';
|
||||||
@@ -106,6 +107,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||||
helpCommand,
|
helpCommand,
|
||||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||||
|
rewindCommand,
|
||||||
await ideCommand(),
|
await ideCommand(),
|
||||||
initCommand,
|
initCommand,
|
||||||
...(this.config?.getMcpEnabled() === false
|
...(this.config?.getMcpEnabled() === false
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
|
|||||||
import type { SessionInfo } from '../utils/sessionUtils.js';
|
import type { SessionInfo } from '../utils/sessionUtils.js';
|
||||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||||
|
import { useMcpStatus } from './hooks/useMcpStatus.js';
|
||||||
import { useSessionStats } from './contexts/SessionContext.js';
|
import { useSessionStats } from './contexts/SessionContext.js';
|
||||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||||
import {
|
import {
|
||||||
@@ -129,6 +130,7 @@ import {
|
|||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||||
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
|
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
|
||||||
|
import { isSlashCommand } from './utils/commandUtils.js';
|
||||||
|
|
||||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||||
return pendingHistoryItems.some((item) => {
|
return pendingHistoryItems.some((item) => {
|
||||||
@@ -689,6 +691,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
toggleDebugProfiler,
|
toggleDebugProfiler,
|
||||||
dispatchExtensionStateUpdate,
|
dispatchExtensionStateUpdate,
|
||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
|
setText: (text: string) => buffer.setText(text),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
setAuthState,
|
setAuthState,
|
||||||
@@ -705,6 +708,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
openPermissionsDialog,
|
openPermissionsDialog,
|
||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
toggleDebugProfiler,
|
toggleDebugProfiler,
|
||||||
|
buffer,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -861,6 +865,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isActive: !embeddedShellFocused,
|
isActive: !embeddedShellFocused,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isMcpReady } = useMcpStatus(config);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messageQueue,
|
messageQueue,
|
||||||
addMessage,
|
addMessage,
|
||||||
@@ -871,6 +877,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isConfigInitialized,
|
isConfigInitialized,
|
||||||
streamingState,
|
streamingState,
|
||||||
submitQuery,
|
submitQuery,
|
||||||
|
isMcpReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelHandlerRef.current = useCallback(
|
cancelHandlerRef.current = useCallback(
|
||||||
@@ -909,10 +916,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
const handleFinalSubmit = useCallback(
|
const handleFinalSubmit = useCallback(
|
||||||
(submittedValue: string) => {
|
(submittedValue: string) => {
|
||||||
addMessage(submittedValue);
|
const isSlash = isSlashCommand(submittedValue.trim());
|
||||||
|
const isIdle = streamingState === StreamingState.Idle;
|
||||||
|
|
||||||
|
if (isSlash || (isIdle && isMcpReady)) {
|
||||||
|
void submitQuery(submittedValue);
|
||||||
|
} else {
|
||||||
|
// Check messageQueue.length === 0 to only notify on the first queued item
|
||||||
|
if (isIdle && !isMcpReady && messageQueue.length === 0) {
|
||||||
|
coreEvents.emitFeedback(
|
||||||
|
'info',
|
||||||
|
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addMessage(submittedValue);
|
||||||
|
}
|
||||||
addInput(submittedValue); // Track input for up-arrow history
|
addInput(submittedValue); // Track input for up-arrow history
|
||||||
},
|
},
|
||||||
[addMessage, addInput],
|
[
|
||||||
|
addMessage,
|
||||||
|
addInput,
|
||||||
|
submitQuery,
|
||||||
|
isMcpReady,
|
||||||
|
streamingState,
|
||||||
|
messageQueue.length,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClearScreen = useCallback(() => {
|
const handleClearScreen = useCallback(() => {
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { rewindCommand } from './rewindCommand.js';
|
||||||
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
|
import { RewindOutcome } from '../components/RewindConfirmation.js';
|
||||||
|
import {
|
||||||
|
type OpenCustomDialogActionReturn,
|
||||||
|
type CommandContext,
|
||||||
|
} from './types.js';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { coreEvents } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockRewindTo = vi.fn();
|
||||||
|
const mockRecordMessage = vi.fn();
|
||||||
|
const mockSetHistory = vi.fn();
|
||||||
|
const mockSendMessageStream = vi.fn();
|
||||||
|
const mockGetChatRecordingService = vi.fn();
|
||||||
|
const mockGetConversation = vi.fn();
|
||||||
|
const mockRemoveComponent = vi.fn();
|
||||||
|
const mockLoadHistory = vi.fn();
|
||||||
|
const mockAddItem = vi.fn();
|
||||||
|
const mockSetPendingItem = vi.fn();
|
||||||
|
const mockResetContext = vi.fn();
|
||||||
|
const mockSetInput = vi.fn();
|
||||||
|
const mockRevertFileChanges = vi.fn();
|
||||||
|
const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root');
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
coreEvents: {
|
||||||
|
...actual.coreEvents,
|
||||||
|
emitFeedback: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../components/RewindViewer.js', () => ({
|
||||||
|
RewindViewer: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/useSessionBrowser.js', () => ({
|
||||||
|
convertSessionToHistoryFormats: vi.fn().mockReturnValue({
|
||||||
|
uiHistory: [
|
||||||
|
{ type: 'user', text: 'old user' },
|
||||||
|
{ type: 'gemini', text: 'old gemini' },
|
||||||
|
],
|
||||||
|
clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/rewindFileOps.js', () => ({
|
||||||
|
revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface RewindViewerProps {
|
||||||
|
onRewind: (
|
||||||
|
messageId: string,
|
||||||
|
newText: string,
|
||||||
|
outcome: RewindOutcome,
|
||||||
|
) => Promise<void>;
|
||||||
|
conversation: unknown;
|
||||||
|
onExit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rewindCommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockGetConversation.mockReturnValue({
|
||||||
|
messages: [{ id: 'msg-1', type: 'user', content: 'hello' }],
|
||||||
|
sessionId: 'test-session',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRewindTo.mockReturnValue({
|
||||||
|
messages: [], // Mocked rewound messages
|
||||||
|
});
|
||||||
|
|
||||||
|
mockGetChatRecordingService.mockReturnValue({
|
||||||
|
getConversation: mockGetConversation,
|
||||||
|
rewindTo: mockRewindTo,
|
||||||
|
recordMessage: mockRecordMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockContext = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: {
|
||||||
|
getGeminiClient: () => ({
|
||||||
|
getChatRecordingService: mockGetChatRecordingService,
|
||||||
|
setHistory: mockSetHistory,
|
||||||
|
sendMessageStream: mockSendMessageStream,
|
||||||
|
}),
|
||||||
|
getSessionId: () => 'test-session-id',
|
||||||
|
getContextManager: () => ({ refresh: mockResetContext }),
|
||||||
|
getProjectRoot: mockGetProjectRoot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
removeComponent: mockRemoveComponent,
|
||||||
|
loadHistory: mockLoadHistory,
|
||||||
|
addItem: mockAddItem,
|
||||||
|
setPendingItem: mockSetPendingItem,
|
||||||
|
},
|
||||||
|
}) as unknown as CommandContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize successfully', async () => {
|
||||||
|
const result = await rewindCommand.action!(mockContext, '');
|
||||||
|
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle RewindOnly correctly', async () => {
|
||||||
|
// 1. Run the command to get the component
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
|
||||||
|
// Access onRewind from props
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
expect(onRewind).toBeDefined();
|
||||||
|
|
||||||
|
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRevertFileChanges).not.toHaveBeenCalled();
|
||||||
|
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
|
||||||
|
expect(mockSetHistory).toHaveBeenCalled();
|
||||||
|
expect(mockResetContext).toHaveBeenCalled();
|
||||||
|
expect(mockLoadHistory).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
expect.objectContaining({ text: 'old user', id: 1 }),
|
||||||
|
expect.objectContaining({ text: 'old gemini', id: 2 }),
|
||||||
|
],
|
||||||
|
'New Prompt',
|
||||||
|
);
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify setInput was NOT called directly (it's handled via loadHistory now)
|
||||||
|
expect(mockSetInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle RewindAndRevert correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRevertFileChanges).toHaveBeenCalledWith(
|
||||||
|
mockGetConversation(),
|
||||||
|
'msg-id-123',
|
||||||
|
);
|
||||||
|
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
|
||||||
|
expect(mockLoadHistory).toHaveBeenCalledWith(
|
||||||
|
expect.any(Array),
|
||||||
|
'New Prompt',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(mockSetInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle RevertOnly correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRevertFileChanges).toHaveBeenCalledWith(
|
||||||
|
mockGetConversation(),
|
||||||
|
'msg-id-123',
|
||||||
|
);
|
||||||
|
expect(mockRewindTo).not.toHaveBeenCalled();
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
|
'info',
|
||||||
|
'File changes reverted.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(mockSetInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Cancel correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRevertFileChanges).not.toHaveBeenCalled();
|
||||||
|
expect(mockRewindTo).not.toHaveBeenCalled();
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(mockSetInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle onExit correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onExit = component.props.onExit;
|
||||||
|
|
||||||
|
onExit();
|
||||||
|
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rewind error correctly', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
mockRewindTo.mockImplementation(() => {
|
||||||
|
throw new Error('Rewind Failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
'Rewind Failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null conversation from rewindTo', async () => {
|
||||||
|
const result = (await rewindCommand.action!(
|
||||||
|
mockContext,
|
||||||
|
'',
|
||||||
|
)) as OpenCustomDialogActionReturn;
|
||||||
|
const component = result.component as ReactElement<RewindViewerProps>;
|
||||||
|
const onRewind = component.props.onRewind;
|
||||||
|
|
||||||
|
mockRewindTo.mockReturnValue(null);
|
||||||
|
|
||||||
|
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
'Could not fetch conversation file',
|
||||||
|
);
|
||||||
|
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if config is missing', () => {
|
||||||
|
const context = { services: {} } as CommandContext;
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(context, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Config not found',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if client is not initialized', () => {
|
||||||
|
const context = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: { getGeminiClient: () => undefined },
|
||||||
|
},
|
||||||
|
}) as unknown as CommandContext;
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(context, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Client not initialized',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if recording service is unavailable', () => {
|
||||||
|
const context = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: {
|
||||||
|
getGeminiClient: () => ({ getChatRecordingService: () => undefined }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as unknown as CommandContext;
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(context, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Recording service unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return info if no conversation found', () => {
|
||||||
|
mockGetConversation.mockReturnValue(null);
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'No conversation found.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return info if no user interactions found', () => {
|
||||||
|
mockGetConversation.mockReturnValue({
|
||||||
|
messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }],
|
||||||
|
sessionId: 'test-session',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = rewindCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Nothing to rewind to.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CommandKind,
|
||||||
|
type CommandContext,
|
||||||
|
type SlashCommand,
|
||||||
|
} from './types.js';
|
||||||
|
import { RewindViewer } from '../components/RewindViewer.js';
|
||||||
|
import { type HistoryItem } from '../types.js';
|
||||||
|
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
|
||||||
|
import { revertFileChanges } from '../utils/rewindFileOps.js';
|
||||||
|
import { RewindOutcome } from '../components/RewindConfirmation.js';
|
||||||
|
import { checkExhaustive } from '../../utils/checks.js';
|
||||||
|
|
||||||
|
import type { Content } from '@google/genai';
|
||||||
|
import type {
|
||||||
|
ChatRecordingService,
|
||||||
|
GeminiClient,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to handle the core logic of rewinding a conversation.
|
||||||
|
* This function encapsulates the steps needed to rewind the conversation,
|
||||||
|
* update the client and UI history, and clear the component.
|
||||||
|
*
|
||||||
|
* @param context The command context.
|
||||||
|
* @param client Gemini client
|
||||||
|
* @param recordingService The chat recording service.
|
||||||
|
* @param messageId The ID of the message to rewind to.
|
||||||
|
* @param newText The new text for the input field after rewinding.
|
||||||
|
*/
|
||||||
|
async function rewindConversation(
|
||||||
|
context: CommandContext,
|
||||||
|
client: GeminiClient,
|
||||||
|
recordingService: ChatRecordingService,
|
||||||
|
messageId: string,
|
||||||
|
newText: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const conversation = recordingService.rewindTo(messageId);
|
||||||
|
if (!conversation) {
|
||||||
|
const errorMsg = 'Could not fetch conversation file';
|
||||||
|
debugLogger.error(errorMsg);
|
||||||
|
context.ui.removeComponent();
|
||||||
|
coreEvents.emitFeedback('error', errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to UI and Client formats
|
||||||
|
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
|
||||||
|
conversation.messages,
|
||||||
|
);
|
||||||
|
|
||||||
|
client.setHistory(clientHistory as Content[]);
|
||||||
|
|
||||||
|
// Reset context manager as we are rewinding history
|
||||||
|
await context.services.config?.getContextManager()?.refresh();
|
||||||
|
|
||||||
|
// Update UI History
|
||||||
|
// We generate IDs based on index for the rewind history
|
||||||
|
const startId = 1;
|
||||||
|
const historyWithIds = uiHistory.map(
|
||||||
|
(item, idx) =>
|
||||||
|
({
|
||||||
|
...item,
|
||||||
|
id: startId + idx,
|
||||||
|
}) as HistoryItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Remove component FIRST to avoid flicker and clear the stage
|
||||||
|
context.ui.removeComponent();
|
||||||
|
|
||||||
|
// 2. Load the rewound history and set the input
|
||||||
|
context.ui.loadHistory(historyWithIds, newText);
|
||||||
|
} catch (error) {
|
||||||
|
// If an error occurs, we still want to remove the component if possible
|
||||||
|
context.ui.removeComponent();
|
||||||
|
coreEvents.emitFeedback(
|
||||||
|
'error',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error during rewind',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rewindCommand: SlashCommand = {
|
||||||
|
name: 'rewind',
|
||||||
|
description: 'Jump back to a specific message and restart the conversation',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: (context) => {
|
||||||
|
const config = context.services.config;
|
||||||
|
if (!config)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Config not found',
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = config.getGeminiClient();
|
||||||
|
if (!client)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Client not initialized',
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordingService = client.getChatRecordingService();
|
||||||
|
if (!recordingService)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Recording service unavailable',
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversation = recordingService.getConversation();
|
||||||
|
if (!conversation)
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'No conversation found.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserInteractions = conversation.messages.some(
|
||||||
|
(msg) => msg.type === 'user',
|
||||||
|
);
|
||||||
|
if (!hasUserInteractions) {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Nothing to rewind to.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'custom_dialog',
|
||||||
|
component: (
|
||||||
|
<RewindViewer
|
||||||
|
conversation={conversation}
|
||||||
|
onExit={() => {
|
||||||
|
context.ui.removeComponent();
|
||||||
|
}}
|
||||||
|
onRewind={async (messageId, newText, outcome) => {
|
||||||
|
switch (outcome) {
|
||||||
|
case RewindOutcome.Cancel:
|
||||||
|
context.ui.removeComponent();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case RewindOutcome.RevertOnly:
|
||||||
|
if (conversation) {
|
||||||
|
await revertFileChanges(conversation, messageId);
|
||||||
|
}
|
||||||
|
context.ui.removeComponent();
|
||||||
|
coreEvents.emitFeedback('info', 'File changes reverted.');
|
||||||
|
return;
|
||||||
|
|
||||||
|
case RewindOutcome.RewindAndRevert:
|
||||||
|
if (conversation) {
|
||||||
|
await revertFileChanges(conversation, messageId);
|
||||||
|
}
|
||||||
|
await rewindConversation(
|
||||||
|
context,
|
||||||
|
client,
|
||||||
|
recordingService,
|
||||||
|
messageId,
|
||||||
|
newText,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case RewindOutcome.RewindOnly:
|
||||||
|
await rewindConversation(
|
||||||
|
context,
|
||||||
|
client,
|
||||||
|
recordingService,
|
||||||
|
messageId,
|
||||||
|
newText,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
|
||||||
|
default:
|
||||||
|
checkExhaustive(outcome);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -66,8 +66,9 @@ export interface CommandContext {
|
|||||||
* Loads a new set of history items, replacing the current history.
|
* Loads a new set of history items, replacing the current history.
|
||||||
*
|
*
|
||||||
* @param history The array of history items to load.
|
* @param history The array of history items to load.
|
||||||
|
* @param postLoadInput Optional text to set in the input buffer after loading history.
|
||||||
*/
|
*/
|
||||||
loadHistory: UseHistoryManagerReturn['loadHistory'];
|
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
|
||||||
/** Toggles a special display mode. */
|
/** Toggles a special display mode. */
|
||||||
toggleCorgiMode: () => void;
|
toggleCorgiMode: () => void;
|
||||||
toggleDebugProfiler: () => void;
|
toggleDebugProfiler: () => void;
|
||||||
|
|||||||
@@ -1911,7 +1911,9 @@ describe('InputPrompt', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\x1B\x1B');
|
stdin.write('\x1B\x1B');
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
||||||
});
|
});
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
|||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
|
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
parseInputForHighlighting,
|
parseInputForHighlighting,
|
||||||
parseSegmentsFromTokens,
|
parseSegmentsFromTokens,
|
||||||
@@ -505,18 +505,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
escapeTimerRef.current = setTimeout(() => {
|
escapeTimerRef.current = setTimeout(() => {
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
return;
|
||||||
// Second ESC
|
|
||||||
resetEscapeState();
|
|
||||||
if (buffer.text.length > 0) {
|
|
||||||
buffer.setText('');
|
|
||||||
resetCompletionState();
|
|
||||||
} else {
|
|
||||||
if (history.length > 0) {
|
|
||||||
onSubmit('/rewind');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Second ESC
|
||||||
|
resetEscapeState();
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
buffer.setText('');
|
||||||
|
resetCompletionState();
|
||||||
|
return;
|
||||||
|
} else if (history.length > 0) {
|
||||||
|
onSubmit('/rewind');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
coreEvents.emitFeedback('info', 'Nothing to rewind to');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ describe('RewindViewer', () => {
|
|||||||
|
|
||||||
// Select
|
// Select
|
||||||
act(() => {
|
act(() => {
|
||||||
|
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
|
||||||
stdin.write('\r');
|
stdin.write('\r');
|
||||||
});
|
});
|
||||||
expect(lastFrame()).toMatchSnapshot('confirmation-dialog');
|
expect(lastFrame()).toMatchSnapshot('confirmation-dialog');
|
||||||
@@ -252,17 +253,16 @@ describe('RewindViewer', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
description: 'removes reference markers',
|
description: 'removes reference markers',
|
||||||
prompt:
|
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
|
||||||
'some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'strips expanded MCP resource content',
|
description: 'strips expanded MCP resource content',
|
||||||
prompt:
|
prompt:
|
||||||
'read @server3:mcp://demo-resource hello\n' +
|
'read @server3:mcp://demo-resource hello\n' +
|
||||||
'--- Content from referenced files ---\n' +
|
`--- Content from referenced files ---\n` +
|
||||||
'\nContent from @server3:mcp://demo-resource:\n' +
|
'\nContent from @server3:mcp://demo-resource:\n' +
|
||||||
'This is the content of the demo resource.\n' +
|
'This is the content of the demo resource.\n' +
|
||||||
'--- End of content ---',
|
`--- End of content ---`,
|
||||||
},
|
},
|
||||||
])('$description', async ({ prompt }) => {
|
])('$description', async ({ prompt }) => {
|
||||||
const conversation = createConversation([
|
const conversation = createConversation([
|
||||||
@@ -281,6 +281,7 @@ describe('RewindViewer', () => {
|
|||||||
|
|
||||||
// Select
|
// Select
|
||||||
act(() => {
|
act(() => {
|
||||||
|
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
|
||||||
stdin.write('\r'); // Select
|
stdin.write('\r'); // Select
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import {
|
import {
|
||||||
@@ -19,8 +19,9 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
|||||||
import { useRewind } from '../hooks/useRewind.js';
|
import { useRewind } from '../hooks/useRewind.js';
|
||||||
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
|
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
|
||||||
import { stripReferenceContent } from '../utils/formatters.js';
|
import { stripReferenceContent } from '../utils/formatters.js';
|
||||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
|
||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
|
import { CliSpinner } from './CliSpinner.js';
|
||||||
|
import { ExpandableText } from './shared/ExpandableText.js';
|
||||||
|
|
||||||
interface RewindViewerProps {
|
interface RewindViewerProps {
|
||||||
conversation: ConversationRecord;
|
conversation: ConversationRecord;
|
||||||
@@ -29,7 +30,7 @@ interface RewindViewerProps {
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
newText: string,
|
newText: string,
|
||||||
outcome: RewindOutcome,
|
outcome: RewindOutcome,
|
||||||
) => void;
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_LINES_PER_BOX = 2;
|
const MAX_LINES_PER_BOX = 2;
|
||||||
@@ -39,6 +40,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
onExit,
|
onExit,
|
||||||
onRewind,
|
onRewind,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isRewinding, setIsRewinding] = useState(false);
|
||||||
const { terminalWidth, terminalHeight } = useUIState();
|
const { terminalWidth, terminalHeight } = useUIState();
|
||||||
const {
|
const {
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
@@ -48,28 +50,58 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
} = useRewind(conversation);
|
} = useRewind(conversation);
|
||||||
|
|
||||||
|
const [highlightedMessageId, setHighlightedMessageId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const interactions = useMemo(
|
const interactions = useMemo(
|
||||||
() => conversation.messages.filter((msg) => msg.type === 'user'),
|
() => conversation.messages.filter((msg) => msg.type === 'user'),
|
||||||
[conversation.messages],
|
[conversation.messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(() => {
|
||||||
() =>
|
const interactionItems = interactions.map((msg, idx) => ({
|
||||||
interactions
|
key: `${msg.id || 'msg'}-${idx}`,
|
||||||
.map((msg, idx) => ({
|
value: msg,
|
||||||
key: `${msg.id || 'msg'}-${idx}`,
|
index: idx,
|
||||||
value: msg,
|
}));
|
||||||
index: idx,
|
|
||||||
}))
|
// Add "Current Position" as the last item
|
||||||
.reverse(),
|
return [
|
||||||
[interactions],
|
...interactionItems,
|
||||||
);
|
{
|
||||||
|
key: 'current-position',
|
||||||
|
value: {
|
||||||
|
id: 'current-position',
|
||||||
|
type: 'user',
|
||||||
|
content: 'Stay at current position',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
} as MessageRecord,
|
||||||
|
index: interactionItems.length,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [interactions]);
|
||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
if (!selectedMessageId) {
|
if (!selectedMessageId) {
|
||||||
if (keyMatchers[Command.ESCAPE](key)) {
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
onExit();
|
onExit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||||
|
if (
|
||||||
|
highlightedMessageId &&
|
||||||
|
highlightedMessageId !== 'current-position'
|
||||||
|
) {
|
||||||
|
setExpandedMessageId(highlightedMessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||||
|
setExpandedMessageId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,6 +121,28 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
||||||
|
|
||||||
if (selectedMessageId) {
|
if (selectedMessageId) {
|
||||||
|
if (isRewinding) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
padding={1}
|
||||||
|
width={terminalWidth}
|
||||||
|
flexDirection="row"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<CliSpinner />
|
||||||
|
</Box>
|
||||||
|
<Text>Rewinding...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMessageId === 'current-position') {
|
||||||
|
onExit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedMessage = interactions.find(
|
const selectedMessage = interactions.find(
|
||||||
(m) => m.id === selectedMessageId,
|
(m) => m.id === selectedMessageId,
|
||||||
);
|
);
|
||||||
@@ -97,7 +151,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
stats={confirmationStats}
|
stats={confirmationStats}
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
timestamp={selectedMessage?.timestamp}
|
timestamp={selectedMessage?.timestamp}
|
||||||
onConfirm={(outcome) => {
|
onConfirm={async (outcome) => {
|
||||||
if (outcome === RewindOutcome.Cancel) {
|
if (outcome === RewindOutcome.Cancel) {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} else {
|
} else {
|
||||||
@@ -109,7 +163,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
? partToString(userPrompt.content)
|
? partToString(userPrompt.content)
|
||||||
: '';
|
: '';
|
||||||
const cleanedText = stripReferenceContent(originalUserText);
|
const cleanedText = stripReferenceContent(originalUserText);
|
||||||
onRewind(selectedMessageId, cleanedText, outcome);
|
setIsRewinding(true);
|
||||||
|
await onRewind(selectedMessageId, cleanedText, outcome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -133,17 +188,48 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
<BaseSelectionList
|
<BaseSelectionList
|
||||||
items={items}
|
items={items}
|
||||||
|
initialIndex={items.length - 1}
|
||||||
isFocused={true}
|
isFocused={true}
|
||||||
showNumbers={false}
|
showNumbers={false}
|
||||||
|
wrapAround={false}
|
||||||
onSelect={(item: MessageRecord) => {
|
onSelect={(item: MessageRecord) => {
|
||||||
const userPrompt = item;
|
const userPrompt = item;
|
||||||
if (userPrompt && userPrompt.id) {
|
if (userPrompt && userPrompt.id) {
|
||||||
selectMessage(userPrompt.id);
|
if (userPrompt.id === 'current-position') {
|
||||||
|
onExit();
|
||||||
|
} else {
|
||||||
|
selectMessage(userPrompt.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onHighlight={(item: MessageRecord) => {
|
||||||
|
if (item.id) {
|
||||||
|
setHighlightedMessageId(item.id);
|
||||||
|
// Collapse when moving selection
|
||||||
|
setExpandedMessageId(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
maxItemsToShow={maxItemsToShow}
|
maxItemsToShow={maxItemsToShow}
|
||||||
renderItem={(itemWrapper, { isSelected }) => {
|
renderItem={(itemWrapper, { isSelected }) => {
|
||||||
const userPrompt = itemWrapper.value;
|
const userPrompt = itemWrapper.value;
|
||||||
|
|
||||||
|
if (userPrompt.id === 'current-position') {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
isSelected ? theme.status.success : theme.text.primary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{partToString(userPrompt.content)}
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Cancel rewind and stay here
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const stats = getStats(userPrompt);
|
const stats = getStats(userPrompt);
|
||||||
const firstFileName = stats?.details?.at(0)?.fileName;
|
const firstFileName = stats?.details?.at(0)?.fileName;
|
||||||
const originalUserText = userPrompt.content
|
const originalUserText = userPrompt.content
|
||||||
@@ -154,25 +240,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Box>
|
<Box>
|
||||||
<MaxSizedBox
|
<ExpandableText
|
||||||
maxWidth={terminalWidth - 4}
|
label={cleanedText}
|
||||||
maxHeight={isSelected ? undefined : MAX_LINES_PER_BOX + 1}
|
isExpanded={expandedMessageId === userPrompt.id}
|
||||||
overflowDirection="bottom"
|
textColor={
|
||||||
>
|
isSelected ? theme.status.success : theme.text.primary
|
||||||
{cleanedText.split('\n').map((line, i) => (
|
}
|
||||||
<Box key={i}>
|
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
|
||||||
<Text
|
maxLines={MAX_LINES_PER_BOX}
|
||||||
color={
|
/>
|
||||||
isSelected
|
|
||||||
? theme.status.success
|
|
||||||
: theme.text.primary
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</MaxSizedBox>
|
|
||||||
</Box>
|
</Box>
|
||||||
{stats ? (
|
{stats ? (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
@@ -203,7 +279,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
(Use Enter to select a message, Esc to close)
|
(Use Enter to select a message, Esc to close, Right/Left to
|
||||||
|
expand/collapse)
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js';
|
||||||
import { CommandKind } from '../commands/types.js';
|
import { CommandKind } from '../commands/types.js';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
@@ -85,7 +85,7 @@ export function SuggestionsDisplay({
|
|||||||
const textColor = isActive ? theme.text.accent : theme.text.secondary;
|
const textColor = isActive ? theme.text.accent : theme.text.secondary;
|
||||||
const isLong = suggestion.value.length >= MAX_WIDTH;
|
const isLong = suggestion.value.length >= MAX_WIDTH;
|
||||||
const labelElement = (
|
const labelElement = (
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={suggestion.value}
|
label={suggestion.value}
|
||||||
matchedIndex={suggestion.matchedIndex}
|
matchedIndex={suggestion.matchedIndex}
|
||||||
userInput={userInput}
|
userInput={userInput}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● some command @file │
|
│ some command @file │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -19,11 +22,14 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● read @server3:mcp://demo-resource hello │
|
│ read @server3:mcp://demo-resource hello │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -63,17 +69,20 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`]
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ Q3 │
|
|
||||||
│ No files have been changed │
|
|
||||||
│ │
|
|
||||||
│ ● Q2 │
|
|
||||||
│ No files have been changed │
|
|
||||||
│ │
|
|
||||||
│ Q1 │
|
│ Q1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Q2 │
|
||||||
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ Q3 │
|
||||||
|
│ No files have been changed │
|
||||||
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -83,17 +92,20 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ Q3 │
|
│ Q1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q2 │
|
│ Q2 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ ● Q1 │
|
│ ● Q3 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -103,17 +115,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`]
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● Q3 │
|
│ Q1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q2 │
|
│ Q2 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q1 │
|
│ Q3 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -123,17 +138,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] =
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ Q3 │
|
│ Q1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ Q2 │
|
│ Q2 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ ● Q1 │
|
│ ● Q3 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -143,11 +161,14 @@ exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● Hello │
|
│ Hello │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -157,17 +178,15 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] =
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● 1 │
|
│ 1 │
|
||||||
│ 2 │
|
│ 2... │
|
||||||
│ 3 │
|
|
||||||
│ 4 │
|
|
||||||
│ 5 │
|
|
||||||
│ 6 │
|
|
||||||
│ 7 │
|
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -177,8 +196,11 @@ exports[`RewindViewer > Rendering > renders 'nothing interesting for empty conve
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -188,14 +210,17 @@ exports[`RewindViewer > updates content when conversation changes (background up
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● Message 2 │
|
|
||||||
│ No files have been changed │
|
|
||||||
│ │
|
|
||||||
│ Message 1 │
|
│ Message 1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Message 2 │
|
||||||
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -205,11 +230,14 @@ exports[`RewindViewer > updates content when conversation changes (background up
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● Message 1 │
|
│ Message 1 │
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -219,22 +247,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
|
│ Line A │
|
||||||
|
│ Line B... │
|
||||||
|
│ No files have been changed │
|
||||||
|
│ │
|
||||||
│ Line 1 │
|
│ Line 1 │
|
||||||
│ Line 2 │
|
│ Line 2... │
|
||||||
│ ... last 5 lines hidden ... │
|
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ ● Line A │
|
│ ● Stay at current position │
|
||||||
│ Line B │
|
│ Cancel rewind and stay here │
|
||||||
│ Line C │
|
|
||||||
│ Line D │
|
|
||||||
│ Line E │
|
|
||||||
│ Line F │
|
|
||||||
│ Line G │
|
|
||||||
│ No files have been changed │
|
|
||||||
│ │
|
│ │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -244,22 +269,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial-
|
|||||||
│ │
|
│ │
|
||||||
│ > Rewind │
|
│ > Rewind │
|
||||||
│ │
|
│ │
|
||||||
│ ● Line 1 │
|
|
||||||
│ Line 2 │
|
|
||||||
│ Line 3 │
|
|
||||||
│ Line 4 │
|
|
||||||
│ Line 5 │
|
|
||||||
│ Line 6 │
|
|
||||||
│ Line 7 │
|
|
||||||
│ No files have been changed │
|
|
||||||
│ │
|
|
||||||
│ Line A │
|
│ Line A │
|
||||||
│ Line B │
|
│ Line B... │
|
||||||
│ ... last 5 lines hidden ... │
|
|
||||||
│ No files have been changed │
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
|
│ Line 1 │
|
||||||
|
│ Line 2... │
|
||||||
|
│ No files have been changed │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select a message, Esc to close) │
|
│ ● Stay at current position │
|
||||||
|
│ Cancel rewind and stay here │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ describe('BaseSelectionList', () => {
|
|||||||
onHighlight: mockOnHighlight,
|
onHighlight: mockOnHighlight,
|
||||||
isFocused,
|
isFocused,
|
||||||
showNumbers,
|
showNumbers,
|
||||||
|
wrapAround: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface BaseSelectionListProps<
|
|||||||
showNumbers?: boolean;
|
showNumbers?: boolean;
|
||||||
showScrollArrows?: boolean;
|
showScrollArrows?: boolean;
|
||||||
maxItemsToShow?: number;
|
maxItemsToShow?: number;
|
||||||
|
wrapAround?: boolean;
|
||||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export function BaseSelectionList<
|
|||||||
showNumbers = true,
|
showNumbers = true,
|
||||||
showScrollArrows = false,
|
showScrollArrows = false,
|
||||||
maxItemsToShow = 10,
|
maxItemsToShow = 10,
|
||||||
|
wrapAround = true,
|
||||||
renderItem,
|
renderItem,
|
||||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||||
const { activeIndex } = useSelectionList({
|
const { activeIndex } = useSelectionList({
|
||||||
@@ -68,6 +70,7 @@ export function BaseSelectionList<
|
|||||||
onHighlight,
|
onHighlight,
|
||||||
isFocused,
|
isFocused,
|
||||||
showNumbers,
|
showNumbers,
|
||||||
|
wrapAround,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
|
|||||||
+30
-10
@@ -1,20 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render } from '../../test-utils/render.js';
|
import { render } from '../../../test-utils/render.js';
|
||||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
|
||||||
|
|
||||||
describe('PrepareLabel', () => {
|
describe('ExpandableText', () => {
|
||||||
const color = 'white';
|
const color = 'white';
|
||||||
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
|
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
|
||||||
|
|
||||||
it('renders plain label when no match (short label)', () => {
|
it('renders plain label when no match (short label)', () => {
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label="simple command"
|
label="simple command"
|
||||||
userInput=""
|
userInput=""
|
||||||
matchedIndex={undefined}
|
matchedIndex={undefined}
|
||||||
@@ -29,7 +29,7 @@ describe('PrepareLabel', () => {
|
|||||||
it('truncates long label when collapsed and no match', () => {
|
it('truncates long label when collapsed and no match', () => {
|
||||||
const long = 'x'.repeat(MAX_WIDTH + 25);
|
const long = 'x'.repeat(MAX_WIDTH + 25);
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={long}
|
label={long}
|
||||||
userInput=""
|
userInput=""
|
||||||
textColor={color}
|
textColor={color}
|
||||||
@@ -47,7 +47,7 @@ describe('PrepareLabel', () => {
|
|||||||
it('shows full long label when expanded and no match', () => {
|
it('shows full long label when expanded and no match', () => {
|
||||||
const long = 'y'.repeat(MAX_WIDTH + 25);
|
const long = 'y'.repeat(MAX_WIDTH + 25);
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={long}
|
label={long}
|
||||||
userInput=""
|
userInput=""
|
||||||
textColor={color}
|
textColor={color}
|
||||||
@@ -66,7 +66,7 @@ describe('PrepareLabel', () => {
|
|||||||
const userInput = 'commit';
|
const userInput = 'commit';
|
||||||
const matchedIndex = label.indexOf(userInput);
|
const matchedIndex = label.indexOf(userInput);
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={label}
|
label={label}
|
||||||
userInput={userInput}
|
userInput={userInput}
|
||||||
matchedIndex={matchedIndex}
|
matchedIndex={matchedIndex}
|
||||||
@@ -86,7 +86,7 @@ describe('PrepareLabel', () => {
|
|||||||
const label = prefix + core + suffix;
|
const label = prefix + core + suffix;
|
||||||
const matchedIndex = prefix.length;
|
const matchedIndex = prefix.length;
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={label}
|
label={label}
|
||||||
userInput={core}
|
userInput={core}
|
||||||
matchedIndex={matchedIndex}
|
matchedIndex={matchedIndex}
|
||||||
@@ -111,7 +111,7 @@ describe('PrepareLabel', () => {
|
|||||||
const label = prefix + core + suffix;
|
const label = prefix + core + suffix;
|
||||||
const matchedIndex = prefix.length;
|
const matchedIndex = prefix.length;
|
||||||
const { lastFrame, unmount } = render(
|
const { lastFrame, unmount } = render(
|
||||||
<PrepareLabel
|
<ExpandableText
|
||||||
label={label}
|
label={label}
|
||||||
userInput={core}
|
userInput={core}
|
||||||
matchedIndex={matchedIndex}
|
matchedIndex={matchedIndex}
|
||||||
@@ -128,4 +128,24 @@ describe('PrepareLabel', () => {
|
|||||||
expect(out).toMatchSnapshot();
|
expect(out).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('respects custom maxWidth', () => {
|
||||||
|
const customWidth = 50;
|
||||||
|
const long = 'z'.repeat(100);
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<ExpandableText
|
||||||
|
label={long}
|
||||||
|
userInput=""
|
||||||
|
textColor={color}
|
||||||
|
isExpanded={false}
|
||||||
|
maxWidth={customWidth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const out = lastFrame();
|
||||||
|
const f = flat(out);
|
||||||
|
expect(f.endsWith('...')).toBe(true);
|
||||||
|
expect(f.length).toBe(customWidth + 3);
|
||||||
|
expect(out).toMatchSnapshot();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+39
-19
@@ -1,29 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
|
export const MAX_WIDTH = 150;
|
||||||
|
|
||||||
export interface PrepareLabelProps {
|
export interface ExpandableTextProps {
|
||||||
label: string;
|
label: string;
|
||||||
matchedIndex?: number;
|
matchedIndex?: number;
|
||||||
userInput: string;
|
userInput?: string;
|
||||||
textColor: string;
|
textColor?: string;
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxLines?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
const _ExpandableText: React.FC<ExpandableTextProps> = ({
|
||||||
label,
|
label,
|
||||||
matchedIndex,
|
matchedIndex,
|
||||||
userInput,
|
userInput = '',
|
||||||
textColor,
|
textColor = theme.text.primary,
|
||||||
isExpanded = false,
|
isExpanded = false,
|
||||||
|
maxWidth = MAX_WIDTH,
|
||||||
|
maxLines,
|
||||||
}) => {
|
}) => {
|
||||||
const hasMatch =
|
const hasMatch =
|
||||||
matchedIndex !== undefined &&
|
matchedIndex !== undefined &&
|
||||||
@@ -33,11 +37,27 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
|||||||
|
|
||||||
// Render the plain label if there's no match
|
// Render the plain label if there's no match
|
||||||
if (!hasMatch) {
|
if (!hasMatch) {
|
||||||
const display = isExpanded
|
let display = label;
|
||||||
? label
|
|
||||||
: label.length > MAX_WIDTH
|
if (!isExpanded) {
|
||||||
? label.slice(0, MAX_WIDTH) + '...'
|
if (maxLines !== undefined) {
|
||||||
: label;
|
const lines = label.split('\n');
|
||||||
|
// 1. Truncate by logical lines
|
||||||
|
let truncated = lines.slice(0, maxLines).join('\n');
|
||||||
|
const hasMoreLines = lines.length > maxLines;
|
||||||
|
|
||||||
|
// 2. Truncate by characters (visual approximation) to prevent massive wrapping
|
||||||
|
if (truncated.length > maxWidth) {
|
||||||
|
truncated = truncated.slice(0, maxWidth) + '...';
|
||||||
|
} else if (hasMoreLines) {
|
||||||
|
truncated += '...';
|
||||||
|
}
|
||||||
|
display = truncated;
|
||||||
|
} else if (label.length > maxWidth) {
|
||||||
|
display = label.slice(0, maxWidth) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text wrap="wrap" color={textColor}>
|
<Text wrap="wrap" color={textColor}>
|
||||||
{display}
|
{display}
|
||||||
@@ -51,18 +71,18 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
|||||||
let after = '';
|
let after = '';
|
||||||
|
|
||||||
// Case 1: Show the full string if it's expanded or already fits
|
// Case 1: Show the full string if it's expanded or already fits
|
||||||
if (isExpanded || label.length <= MAX_WIDTH) {
|
if (isExpanded || label.length <= maxWidth) {
|
||||||
before = label.slice(0, matchedIndex);
|
before = label.slice(0, matchedIndex);
|
||||||
match = label.slice(matchedIndex, matchedIndex + matchLength);
|
match = label.slice(matchedIndex, matchedIndex + matchLength);
|
||||||
after = label.slice(matchedIndex + matchLength);
|
after = label.slice(matchedIndex + matchLength);
|
||||||
}
|
}
|
||||||
// Case 2: The match itself is too long, so we only show a truncated portion of the match
|
// Case 2: The match itself is too long, so we only show a truncated portion of the match
|
||||||
else if (matchLength >= MAX_WIDTH) {
|
else if (matchLength >= maxWidth) {
|
||||||
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
|
match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...';
|
||||||
}
|
}
|
||||||
// Case 3: Truncate the string to create a window around the match
|
// Case 3: Truncate the string to create a window around the match
|
||||||
else {
|
else {
|
||||||
const contextSpace = MAX_WIDTH - matchLength;
|
const contextSpace = maxWidth - matchLength;
|
||||||
const beforeSpace = Math.floor(contextSpace / 2);
|
const beforeSpace = Math.floor(contextSpace / 2);
|
||||||
const afterSpace = Math.ceil(contextSpace / 2);
|
const afterSpace = Math.ceil(contextSpace / 2);
|
||||||
|
|
||||||
@@ -113,4 +133,4 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PrepareLabel = React.memo(_PrepareLabel);
|
export const ExpandableText = React.memo(_ExpandableText);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > creates centered window around match when collapsed 1`] = `
|
||||||
|
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||||
|
components//and/then/some/more/components//and/..."
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > shows full long label when expanded and no match 1`] = `
|
||||||
|
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||||
|
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > truncates long label when collapsed and no match 1`] = `
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandablePrompt > truncates match itself when match is very long 1`] = `
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||||
|
`;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
|
||||||
|
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||||
|
components//and/then/some/more/components//and/..."
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
|
||||||
|
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||||
|
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||||
|
`;
|
||||||
@@ -212,6 +212,9 @@ describe('KeypressContext', () => {
|
|||||||
name: 'return',
|
name: 'return',
|
||||||
sequence: '\r',
|
sequence: '\r',
|
||||||
insertable: true,
|
insertable: true,
|
||||||
|
shift: true,
|
||||||
|
meta: false,
|
||||||
|
ctrl: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler {
|
|||||||
keypressHandler({
|
keypressHandler({
|
||||||
...key,
|
...key,
|
||||||
name: 'return',
|
name: 'return',
|
||||||
|
shift: true, // to make it a newline, not a submission
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
sequence: '\r',
|
sequence: '\r',
|
||||||
insertable: true,
|
insertable: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,12 +18,17 @@ import {
|
|||||||
isNodeError,
|
isNodeError,
|
||||||
unescapePath,
|
unescapePath,
|
||||||
ReadManyFilesTool,
|
ReadManyFilesTool,
|
||||||
|
REFERENCE_CONTENT_START,
|
||||||
|
REFERENCE_CONTENT_END,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
|
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
|
||||||
import { ToolCallStatus } from '../types.js';
|
import { ToolCallStatus } from '../types.js';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
|
||||||
|
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
|
||||||
|
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
|
||||||
|
|
||||||
interface HandleAtCommandParams {
|
interface HandleAtCommandParams {
|
||||||
query: string;
|
query: string;
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -499,10 +504,17 @@ export async function handleAtCommand({
|
|||||||
const resourceResults = await Promise.all(resourcePromises);
|
const resourceResults = await Promise.all(resourcePromises);
|
||||||
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
|
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
|
||||||
let resourceErrorOccurred = false;
|
let resourceErrorOccurred = false;
|
||||||
|
let hasAddedReferenceHeader = false;
|
||||||
|
|
||||||
for (const result of resourceResults) {
|
for (const result of resourceResults) {
|
||||||
resourceReadDisplays.push(result.display);
|
resourceReadDisplays.push(result.display);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
if (!hasAddedReferenceHeader) {
|
||||||
|
processedQueryParts.push({
|
||||||
|
text: REF_CONTENT_HEADER,
|
||||||
|
});
|
||||||
|
hasAddedReferenceHeader = true;
|
||||||
|
}
|
||||||
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
|
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
|
||||||
processedQueryParts.push(...result.parts);
|
processedQueryParts.push(...result.parts);
|
||||||
} else {
|
} else {
|
||||||
@@ -540,6 +552,9 @@ export async function handleAtCommand({
|
|||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (hasAddedReferenceHeader) {
|
||||||
|
processedQueryParts.push({ text: REF_CONTENT_FOOTER });
|
||||||
|
}
|
||||||
return { processedQuery: processedQueryParts };
|
return { processedQuery: processedQueryParts };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,9 +585,12 @@ export async function handleAtCommand({
|
|||||||
|
|
||||||
if (Array.isArray(result.llmContent)) {
|
if (Array.isArray(result.llmContent)) {
|
||||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||||
processedQueryParts.push({
|
if (!hasAddedReferenceHeader) {
|
||||||
text: '\n--- Content from referenced files ---',
|
processedQueryParts.push({
|
||||||
});
|
text: REF_CONTENT_HEADER,
|
||||||
|
});
|
||||||
|
hasAddedReferenceHeader = true;
|
||||||
|
}
|
||||||
for (const part of result.llmContent) {
|
for (const part of result.llmContent) {
|
||||||
if (typeof part === 'string') {
|
if (typeof part === 'string') {
|
||||||
const match = fileContentRegex.exec(part);
|
const match = fileContentRegex.exec(part);
|
||||||
|
|||||||
@@ -156,11 +156,22 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const setupProcessorHook = async (
|
const setupProcessorHook = async (
|
||||||
builtinCommands: SlashCommand[] = [],
|
options: {
|
||||||
fileCommands: SlashCommand[] = [],
|
builtinCommands?: SlashCommand[];
|
||||||
mcpCommands: SlashCommand[] = [],
|
fileCommands?: SlashCommand[];
|
||||||
setIsProcessing = vi.fn(),
|
mcpCommands?: SlashCommand[];
|
||||||
|
setIsProcessing?: (isProcessing: boolean) => void;
|
||||||
|
refreshStatic?: () => void;
|
||||||
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
|
const {
|
||||||
|
builtinCommands = [],
|
||||||
|
fileCommands = [],
|
||||||
|
mcpCommands = [],
|
||||||
|
setIsProcessing = vi.fn(),
|
||||||
|
refreshStatic = vi.fn(),
|
||||||
|
} = options;
|
||||||
|
|
||||||
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands));
|
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands));
|
||||||
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
|
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
|
||||||
mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands));
|
mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands));
|
||||||
@@ -177,7 +188,7 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
mockAddItem,
|
mockAddItem,
|
||||||
mockClearItems,
|
mockClearItems,
|
||||||
mockLoadHistory,
|
mockLoadHistory,
|
||||||
vi.fn(), // refreshStatic
|
refreshStatic,
|
||||||
vi.fn(), // toggleVimEnabled
|
vi.fn(), // toggleVimEnabled
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
{
|
{
|
||||||
@@ -195,6 +206,7 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
toggleDebugProfiler: vi.fn(),
|
toggleDebugProfiler: vi.fn(),
|
||||||
dispatchExtensionStateUpdate: vi.fn(),
|
dispatchExtensionStateUpdate: vi.fn(),
|
||||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||||
|
setText: vi.fn(),
|
||||||
},
|
},
|
||||||
new Map(), // extensionsUpdateState
|
new Map(), // extensionsUpdateState
|
||||||
true, // isConfigInitialized
|
true, // isConfigInitialized
|
||||||
@@ -233,7 +245,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
context.ui.clear();
|
context.ui.clear();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await setupProcessorHook([clearCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [clearCommand],
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.handleSlashCommand('/clear');
|
await result.current.handleSlashCommand('/clear');
|
||||||
@@ -250,7 +264,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
context.ui.clear();
|
context.ui.clear();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await setupProcessorHook([clearCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [clearCommand],
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.handleSlashCommand('/clear');
|
await result.current.handleSlashCommand('/clear');
|
||||||
@@ -270,7 +286,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
|
|
||||||
it('should call loadCommands and populate state after mounting', async () => {
|
it('should call loadCommands and populate state after mounting', async () => {
|
||||||
const testCommand = createTestCommand({ name: 'test' });
|
const testCommand = createTestCommand({ name: 'test' });
|
||||||
const result = await setupProcessorHook([testCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [testCommand],
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.slashCommands).toHaveLength(1);
|
expect(result.current.slashCommands).toHaveLength(1);
|
||||||
@@ -284,7 +302,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
|
|
||||||
it('should provide an immutable array of commands to consumers', async () => {
|
it('should provide an immutable array of commands to consumers', async () => {
|
||||||
const testCommand = createTestCommand({ name: 'test' });
|
const testCommand = createTestCommand({ name: 'test' });
|
||||||
const result = await setupProcessorHook([testCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [testCommand],
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.slashCommands).toHaveLength(1);
|
expect(result.current.slashCommands).toHaveLength(1);
|
||||||
@@ -312,7 +332,10 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
CommandKind.FILE,
|
CommandKind.FILE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await setupProcessorHook([builtinCommand], [fileCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [builtinCommand],
|
||||||
|
fileCommands: [fileCommand],
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// The service should only return one command with the name 'override'
|
// The service should only return one command with the name 'override'
|
||||||
@@ -362,7 +385,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = await setupProcessorHook([parentCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [parentCommand],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -396,7 +421,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = await setupProcessorHook([parentCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [parentCommand],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -420,7 +447,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
|
|
||||||
it('sets isProcessing to false if the the input is not a command', async () => {
|
it('sets isProcessing to false if the the input is not a command', async () => {
|
||||||
const setMockIsProcessing = vi.fn();
|
const setMockIsProcessing = vi.fn();
|
||||||
const result = await setupProcessorHook([], [], [], setMockIsProcessing);
|
const result = await setupProcessorHook({
|
||||||
|
setIsProcessing: setMockIsProcessing,
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.handleSlashCommand('imnotacommand');
|
await result.current.handleSlashCommand('imnotacommand');
|
||||||
@@ -436,12 +465,10 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
action: vi.fn().mockRejectedValue(new Error('oh no!')),
|
action: vi.fn().mockRejectedValue(new Error('oh no!')),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await setupProcessorHook(
|
const result = await setupProcessorHook({
|
||||||
[failCommand],
|
builtinCommands: [failCommand],
|
||||||
[],
|
setIsProcessing: setMockIsProcessing,
|
||||||
[],
|
});
|
||||||
setMockIsProcessing,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
|
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
|
||||||
|
|
||||||
@@ -460,12 +487,10 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
action: () => new Promise((resolve) => setTimeout(resolve, 50)),
|
action: () => new Promise((resolve) => setTimeout(resolve, 50)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await setupProcessorHook(
|
const result = await setupProcessorHook({
|
||||||
[command],
|
builtinCommands: [command],
|
||||||
[],
|
setIsProcessing: mockSetIsProcessing,
|
||||||
[],
|
});
|
||||||
mockSetIsProcessing,
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
const executionPromise = act(async () => {
|
const executionPromise = act(async () => {
|
||||||
@@ -507,7 +532,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ type: 'dialog', dialog: dialogType }),
|
.mockResolvedValue({ type: 'dialog', dialog: dialogType }),
|
||||||
});
|
});
|
||||||
const result = await setupProcessorHook([command]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [command],
|
||||||
|
});
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.slashCommands).toHaveLength(1),
|
expect(result.current.slashCommands).toHaveLength(1),
|
||||||
);
|
);
|
||||||
@@ -536,20 +563,42 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }],
|
clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const result = await setupProcessorHook([command]);
|
|
||||||
|
const mockRefreshStatic = vi.fn();
|
||||||
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [command],
|
||||||
|
refreshStatic: mockRefreshStatic,
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.handleSlashCommand('/load');
|
await result.current.handleSlashCommand('/load');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ui.clear() is called which calls refreshStatic()
|
||||||
expect(mockClearItems).toHaveBeenCalledTimes(1);
|
expect(mockClearItems).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
|
||||||
expect(mockAddItem).toHaveBeenCalledWith(
|
expect(mockAddItem).toHaveBeenCalledWith(
|
||||||
{ type: 'user', text: 'old prompt' },
|
{ type: 'user', text: 'old prompt' },
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call refreshStatic exactly once when ui.loadHistory is called', async () => {
|
||||||
|
const mockRefreshStatic = vi.fn();
|
||||||
|
const result = await setupProcessorHook({
|
||||||
|
refreshStatic: mockRefreshStatic,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.commandContext.ui.loadHistory([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLoadHistory).toHaveBeenCalled();
|
||||||
|
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle a "quit" action', async () => {
|
it('should handle a "quit" action', async () => {
|
||||||
const quitAction = vi
|
const quitAction = vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -558,7 +607,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
name: 'exit',
|
name: 'exit',
|
||||||
action: quitAction,
|
action: quitAction,
|
||||||
});
|
});
|
||||||
const result = await setupProcessorHook([command]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [command],
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
@@ -581,7 +632,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
CommandKind.FILE,
|
CommandKind.FILE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await setupProcessorHook([], [fileCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
fileCommands: [fileCommand],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
let actionResult;
|
let actionResult;
|
||||||
@@ -613,7 +666,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
CommandKind.MCP_PROMPT,
|
CommandKind.MCP_PROMPT,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await setupProcessorHook([], [], [mcpCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
mcpCommands: [mcpCommand],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
let actionResult;
|
let actionResult;
|
||||||
@@ -636,7 +691,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
describe('Command Parsing and Matching', () => {
|
describe('Command Parsing and Matching', () => {
|
||||||
it('should be case-sensitive', async () => {
|
it('should be case-sensitive', async () => {
|
||||||
const command = createTestCommand({ name: 'test' });
|
const command = createTestCommand({ name: 'test' });
|
||||||
const result = await setupProcessorHook([command]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [command],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -662,7 +719,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
description: 'a command with an alias',
|
description: 'a command with an alias',
|
||||||
action,
|
action,
|
||||||
});
|
});
|
||||||
const result = await setupProcessorHook([command]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [command],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -678,7 +737,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
it('should handle extra whitespace around the command', async () => {
|
it('should handle extra whitespace around the command', async () => {
|
||||||
const action = vi.fn();
|
const action = vi.fn();
|
||||||
const command = createTestCommand({ name: 'test', action });
|
const command = createTestCommand({ name: 'test', action });
|
||||||
const result = await setupProcessorHook([command]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [command],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -691,7 +752,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
it('should handle `?` as a command prefix', async () => {
|
it('should handle `?` as a command prefix', async () => {
|
||||||
const action = vi.fn();
|
const action = vi.fn();
|
||||||
const command = createTestCommand({ name: 'help', action });
|
const command = createTestCommand({ name: 'help', action });
|
||||||
const result = await setupProcessorHook([command]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [command],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -720,7 +783,10 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
CommandKind.FILE,
|
CommandKind.FILE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await setupProcessorHook([], [fileCommand], [mcpCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
fileCommands: [fileCommand],
|
||||||
|
mcpCommands: [mcpCommand],
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// The service should only return one command with the name 'override'
|
// The service should only return one command with the name 'override'
|
||||||
@@ -756,7 +822,10 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
|
|
||||||
// The order of commands in the final loaded array is not guaranteed,
|
// The order of commands in the final loaded array is not guaranteed,
|
||||||
// so the test must work regardless of which comes first.
|
// so the test must work regardless of which comes first.
|
||||||
const result = await setupProcessorHook([quitCommand], [exitCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [quitCommand],
|
||||||
|
fileCommands: [exitCommand],
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.slashCommands).toHaveLength(2);
|
expect(result.current.slashCommands).toHaveLength(2);
|
||||||
@@ -783,7 +852,10 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
CommandKind.FILE,
|
CommandKind.FILE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await setupProcessorHook([quitCommand], [exitCommand]);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: [quitCommand],
|
||||||
|
fileCommands: [exitCommand],
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
|
await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -879,7 +951,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
desc: 'command path when alias is used',
|
desc: 'command path when alias is used',
|
||||||
},
|
},
|
||||||
])('should log $desc', async ({ command, expectedLog }) => {
|
])('should log $desc', async ({ command, expectedLog }) => {
|
||||||
const result = await setupProcessorHook(loggingTestCommands);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: loggingTestCommands,
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
|
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -898,7 +972,9 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
{ command: '/bogusbogusbogus', desc: 'bogus command' },
|
{ command: '/bogusbogusbogus', desc: 'bogus command' },
|
||||||
{ command: '/unknown', desc: 'unknown command' },
|
{ command: '/unknown', desc: 'unknown command' },
|
||||||
])('should not log for $desc', async ({ command }) => {
|
])('should not log for $desc', async ({ command }) => {
|
||||||
const result = await setupProcessorHook(loggingTestCommands);
|
const result = await setupProcessorHook({
|
||||||
|
builtinCommands: loggingTestCommands,
|
||||||
|
});
|
||||||
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
|
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ interface SlashCommandProcessorActions {
|
|||||||
toggleDebugProfiler: () => void;
|
toggleDebugProfiler: () => void;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||||
|
setText: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,7 +211,13 @@ export const useSlashCommandProcessor = (
|
|||||||
refreshStatic();
|
refreshStatic();
|
||||||
setBannerVisible(false);
|
setBannerVisible(false);
|
||||||
},
|
},
|
||||||
loadHistory,
|
loadHistory: (history, postLoadInput) => {
|
||||||
|
loadHistory(history);
|
||||||
|
refreshStatic();
|
||||||
|
if (postLoadInput !== undefined) {
|
||||||
|
actions.setText(postLoadInput);
|
||||||
|
}
|
||||||
|
},
|
||||||
setDebugMessage: actions.setDebugMessage,
|
setDebugMessage: actions.setDebugMessage,
|
||||||
pendingItem,
|
pendingItem,
|
||||||
setPendingItem,
|
setPendingItem,
|
||||||
|
|||||||
@@ -1962,73 +1962,6 @@ describe('useGeminiStream', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MCP Discovery State', () => {
|
|
||||||
it('should block non-slash command queries when discovery is in progress and servers exist', async () => {
|
|
||||||
const mockMcpClientManager = {
|
|
||||||
getDiscoveryState: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
|
|
||||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
|
||||||
};
|
|
||||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
|
||||||
|
|
||||||
const { result } = renderTestHook();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitQuery('test query');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
|
||||||
'info',
|
|
||||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
|
||||||
);
|
|
||||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT block queries when discovery is NOT_STARTED but there are no servers', async () => {
|
|
||||||
const mockMcpClientManager = {
|
|
||||||
getDiscoveryState: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(MCPDiscoveryState.NOT_STARTED),
|
|
||||||
getMcpServerCount: vi.fn().mockReturnValue(0),
|
|
||||||
};
|
|
||||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
|
||||||
|
|
||||||
const { result } = renderTestHook();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitQuery('test query');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
|
|
||||||
'info',
|
|
||||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
|
||||||
);
|
|
||||||
expect(mockSendMessageStream).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT block slash commands even when discovery is in progress', async () => {
|
|
||||||
const mockMcpClientManager = {
|
|
||||||
getDiscoveryState: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
|
|
||||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
|
||||||
};
|
|
||||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
|
||||||
|
|
||||||
const { result } = renderTestHook();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitQuery('/help');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
|
|
||||||
'info',
|
|
||||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handleFinishedEvent', () => {
|
describe('handleFinishedEvent', () => {
|
||||||
it('should add info message for MAX_TOKENS finish reason', async () => {
|
it('should add info message for MAX_TOKENS finish reason', async () => {
|
||||||
// Setup mock to return a stream with MAX_TOKENS finish reason
|
// Setup mock to return a stream with MAX_TOKENS finish reason
|
||||||
@@ -3182,68 +3115,4 @@ describe('useGeminiStream', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MCP Server Initialization', () => {
|
|
||||||
it('should allow slash commands to run while MCP servers are initializing', async () => {
|
|
||||||
const mockMcpClientManager = {
|
|
||||||
getDiscoveryState: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
|
|
||||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
|
||||||
};
|
|
||||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
|
||||||
|
|
||||||
const { result } = renderTestHook();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitQuery('/help');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Slash command should be handled, and no Gemini call should be made.
|
|
||||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help');
|
|
||||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block normal prompts and provide feedback while MCP servers are initializing', async () => {
|
|
||||||
const mockMcpClientManager = {
|
|
||||||
getDiscoveryState: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
|
|
||||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
|
||||||
};
|
|
||||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
|
||||||
const { result } = renderTestHook();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitQuery('a normal prompt');
|
|
||||||
});
|
|
||||||
|
|
||||||
// No slash command, no Gemini call, but feedback should be emitted.
|
|
||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
|
||||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
|
||||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
|
||||||
'info',
|
|
||||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow normal prompts to run when MCP servers are finished initializing', async () => {
|
|
||||||
const mockMcpClientManager = {
|
|
||||||
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED),
|
|
||||||
getMcpServerCount: vi.fn().mockReturnValue(1),
|
|
||||||
};
|
|
||||||
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
|
|
||||||
|
|
||||||
const { result } = renderTestHook();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitQuery('a normal prompt');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prompt should be sent to Gemini.
|
|
||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
|
||||||
expect(mockSendMessageStream).toHaveBeenCalled();
|
|
||||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
ValidationRequiredError,
|
ValidationRequiredError,
|
||||||
coreEvents,
|
coreEvents,
|
||||||
CoreEvent,
|
CoreEvent,
|
||||||
MCPDiscoveryState,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type {
|
import type {
|
||||||
Config,
|
Config,
|
||||||
@@ -803,7 +802,12 @@ export const useGeminiStream = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAgentExecutionStoppedEvent = useCallback(
|
const handleAgentExecutionStoppedEvent = useCallback(
|
||||||
(reason: string, userMessageTimestamp: number, systemMessage?: string) => {
|
(
|
||||||
|
reason: string,
|
||||||
|
userMessageTimestamp: number,
|
||||||
|
systemMessage?: string,
|
||||||
|
contextCleared?: boolean,
|
||||||
|
) => {
|
||||||
if (pendingHistoryItemRef.current) {
|
if (pendingHistoryItemRef.current) {
|
||||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||||
setPendingHistoryItem(null);
|
setPendingHistoryItem(null);
|
||||||
@@ -815,13 +819,27 @@ export const useGeminiStream = (
|
|||||||
},
|
},
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
|
if (contextCleared) {
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Conversation context has been cleared.',
|
||||||
|
},
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
setIsResponding(false);
|
setIsResponding(false);
|
||||||
},
|
},
|
||||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setIsResponding],
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setIsResponding],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAgentExecutionBlockedEvent = useCallback(
|
const handleAgentExecutionBlockedEvent = useCallback(
|
||||||
(reason: string, userMessageTimestamp: number, systemMessage?: string) => {
|
(
|
||||||
|
reason: string,
|
||||||
|
userMessageTimestamp: number,
|
||||||
|
systemMessage?: string,
|
||||||
|
contextCleared?: boolean,
|
||||||
|
) => {
|
||||||
if (pendingHistoryItemRef.current) {
|
if (pendingHistoryItemRef.current) {
|
||||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||||
setPendingHistoryItem(null);
|
setPendingHistoryItem(null);
|
||||||
@@ -833,6 +851,15 @@ export const useGeminiStream = (
|
|||||||
},
|
},
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
|
if (contextCleared) {
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Conversation context has been cleared.',
|
||||||
|
},
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||||
);
|
);
|
||||||
@@ -873,6 +900,7 @@ export const useGeminiStream = (
|
|||||||
event.value.reason,
|
event.value.reason,
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
event.value.systemMessage,
|
event.value.systemMessage,
|
||||||
|
event.value.contextCleared,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ServerGeminiEventType.AgentExecutionBlocked:
|
case ServerGeminiEventType.AgentExecutionBlocked:
|
||||||
@@ -880,6 +908,7 @@ export const useGeminiStream = (
|
|||||||
event.value.reason,
|
event.value.reason,
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
event.value.systemMessage,
|
event.value.systemMessage,
|
||||||
|
event.value.contextCleared,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case ServerGeminiEventType.ChatCompressed:
|
case ServerGeminiEventType.ChatCompressed:
|
||||||
@@ -961,25 +990,6 @@ export const useGeminiStream = (
|
|||||||
async ({ metadata: spanMetadata }) => {
|
async ({ metadata: spanMetadata }) => {
|
||||||
spanMetadata.input = query;
|
spanMetadata.input = query;
|
||||||
|
|
||||||
const discoveryState = config
|
|
||||||
.getMcpClientManager()
|
|
||||||
?.getDiscoveryState();
|
|
||||||
const mcpServerCount =
|
|
||||||
config.getMcpClientManager()?.getMcpServerCount() ?? 0;
|
|
||||||
if (
|
|
||||||
!options?.isContinuation &&
|
|
||||||
typeof query === 'string' &&
|
|
||||||
!isSlashCommand(query.trim()) &&
|
|
||||||
mcpServerCount > 0 &&
|
|
||||||
discoveryState !== MCPDiscoveryState.COMPLETED
|
|
||||||
) {
|
|
||||||
coreEvents.emitFeedback(
|
|
||||||
'info',
|
|
||||||
'Waiting for MCP servers to initialize... Slash commands are still available.',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryId = `${Date.now()}-${Math.random()}`;
|
const queryId = `${Date.now()}-${Math.random()}`;
|
||||||
activeQueryIdRef.current = queryId;
|
activeQueryIdRef.current = queryId;
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { render } from '../../test-utils/render.js';
|
||||||
|
import { useMcpStatus } from './useMcpStatus.js';
|
||||||
|
import {
|
||||||
|
MCPDiscoveryState,
|
||||||
|
type Config,
|
||||||
|
CoreEvent,
|
||||||
|
coreEvents,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
describe('useMcpStatus', () => {
|
||||||
|
let mockConfig: Config;
|
||||||
|
let mockMcpClientManager: {
|
||||||
|
getDiscoveryState: Mock<() => MCPDiscoveryState>;
|
||||||
|
getMcpServerCount: Mock<() => number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMcpClientManager = {
|
||||||
|
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.NOT_STARTED),
|
||||||
|
getMcpServerCount: vi.fn().mockReturnValue(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockConfig = {
|
||||||
|
getMcpClientManager: vi.fn().mockReturnValue(mockMcpClientManager),
|
||||||
|
} as unknown as Config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderMcpStatusHook = (config: Config) => {
|
||||||
|
let hookResult: ReturnType<typeof useMcpStatus>;
|
||||||
|
function TestComponent({ config }: { config: Config }) {
|
||||||
|
hookResult = useMcpStatus(config);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
render(<TestComponent config={config} />);
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
get current() {
|
||||||
|
return hookResult;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should initialize with correct values (no servers)', () => {
|
||||||
|
const { result } = renderMcpStatusHook(mockConfig);
|
||||||
|
|
||||||
|
expect(result.current.discoveryState).toBe(MCPDiscoveryState.NOT_STARTED);
|
||||||
|
expect(result.current.mcpServerCount).toBe(0);
|
||||||
|
expect(result.current.isMcpReady).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with correct values (with servers, not started)', () => {
|
||||||
|
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
|
||||||
|
const { result } = renderMcpStatusHook(mockConfig);
|
||||||
|
|
||||||
|
expect(result.current.isMcpReady).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be ready while in progress', () => {
|
||||||
|
mockMcpClientManager.getDiscoveryState.mockReturnValue(
|
||||||
|
MCPDiscoveryState.IN_PROGRESS,
|
||||||
|
);
|
||||||
|
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
|
||||||
|
const { result } = renderMcpStatusHook(mockConfig);
|
||||||
|
|
||||||
|
expect(result.current.isMcpReady).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update state when McpClientUpdate is emitted', () => {
|
||||||
|
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
|
||||||
|
mockMcpClientManager.getDiscoveryState.mockReturnValue(
|
||||||
|
MCPDiscoveryState.IN_PROGRESS,
|
||||||
|
);
|
||||||
|
const { result } = renderMcpStatusHook(mockConfig);
|
||||||
|
|
||||||
|
expect(result.current.isMcpReady).toBe(false);
|
||||||
|
|
||||||
|
mockMcpClientManager.getDiscoveryState.mockReturnValue(
|
||||||
|
MCPDiscoveryState.COMPLETED,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emit(CoreEvent.McpClientUpdate, new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.discoveryState).toBe(MCPDiscoveryState.COMPLETED);
|
||||||
|
expect(result.current.isMcpReady).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
type Config,
|
||||||
|
coreEvents,
|
||||||
|
MCPDiscoveryState,
|
||||||
|
CoreEvent,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
export function useMcpStatus(config: Config) {
|
||||||
|
const [discoveryState, setDiscoveryState] = useState<MCPDiscoveryState>(
|
||||||
|
() =>
|
||||||
|
config.getMcpClientManager()?.getDiscoveryState() ??
|
||||||
|
MCPDiscoveryState.NOT_STARTED,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mcpServerCount, setMcpServerCount] = useState<number>(
|
||||||
|
() => config.getMcpClientManager()?.getMcpServerCount() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => {
|
||||||
|
const manager = config.getMcpClientManager();
|
||||||
|
if (manager) {
|
||||||
|
setDiscoveryState(manager.getDiscoveryState());
|
||||||
|
setMcpServerCount(manager.getMcpServerCount());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
coreEvents.on(CoreEvent.McpClientUpdate, onChange);
|
||||||
|
return () => {
|
||||||
|
coreEvents.off(CoreEvent.McpClientUpdate, onChange);
|
||||||
|
};
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// We are ready if discovery has completed, OR if it hasn't even started and there are no servers.
|
||||||
|
const isMcpReady =
|
||||||
|
discoveryState === MCPDiscoveryState.COMPLETED ||
|
||||||
|
(discoveryState === MCPDiscoveryState.NOT_STARTED && mcpServerCount === 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
discoveryState,
|
||||||
|
mcpServerCount,
|
||||||
|
isMcpReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: boolean;
|
isConfigInitialized: boolean;
|
||||||
streamingState: StreamingState;
|
streamingState: StreamingState;
|
||||||
submitQuery: (query: string) => void;
|
submitQuery: (query: string) => void;
|
||||||
|
isMcpReady: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
let hookResult: ReturnType<typeof useMessageQueue>;
|
let hookResult: ReturnType<typeof useMessageQueue>;
|
||||||
function TestComponent(props: typeof initialProps) {
|
function TestComponent(props: typeof initialProps) {
|
||||||
@@ -51,6 +52,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Idle,
|
streamingState: StreamingState.Idle,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.messageQueue).toEqual([]);
|
expect(result.current.messageQueue).toEqual([]);
|
||||||
@@ -62,6 +64,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -80,6 +83,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -100,6 +104,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -120,6 +125,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -133,11 +139,12 @@ describe('useMessageQueue', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should auto-submit queued messages when transitioning to Idle', async () => {
|
it('should auto-submit queued messages when transitioning to Idle and MCP is ready', async () => {
|
||||||
const { result, rerender } = renderMessageQueueHook({
|
const { result, rerender } = renderMessageQueueHook({
|
||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add some messages
|
// Add some messages
|
||||||
@@ -157,11 +164,37 @@ describe('useMessageQueue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should wait for MCP readiness before auto-submitting', async () => {
|
||||||
|
const { result, rerender } = renderMessageQueueHook({
|
||||||
|
isConfigInitialized: true,
|
||||||
|
streamingState: StreamingState.Idle,
|
||||||
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add some messages while Idle but MCP not ready
|
||||||
|
act(() => {
|
||||||
|
result.current.addMessage('Delayed message');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messageQueue).toEqual(['Delayed message']);
|
||||||
|
expect(mockSubmitQuery).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Transition MCP to ready
|
||||||
|
rerender({ isMcpReady: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSubmitQuery).toHaveBeenCalledWith('Delayed message');
|
||||||
|
expect(result.current.messageQueue).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not auto-submit when queue is empty', () => {
|
it('should not auto-submit when queue is empty', () => {
|
||||||
const { rerender } = renderMessageQueueHook({
|
const { rerender } = renderMessageQueueHook({
|
||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transition to Idle with empty queue
|
// Transition to Idle with empty queue
|
||||||
@@ -175,6 +208,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add messages
|
// Add messages
|
||||||
@@ -194,6 +228,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Idle,
|
streamingState: StreamingState.Idle,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start responding
|
// Start responding
|
||||||
@@ -235,6 +270,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add multiple messages
|
// Add multiple messages
|
||||||
@@ -265,6 +301,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let poppedMessages: string | undefined = 'not-undefined';
|
let poppedMessages: string | undefined = 'not-undefined';
|
||||||
@@ -281,6 +318,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -301,6 +339,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -330,6 +369,7 @@ describe('useMessageQueue', () => {
|
|||||||
isConfigInitialized: true,
|
isConfigInitialized: true,
|
||||||
streamingState: StreamingState.Responding,
|
streamingState: StreamingState.Responding,
|
||||||
submitQuery: mockSubmitQuery,
|
submitQuery: mockSubmitQuery,
|
||||||
|
isMcpReady: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add messages
|
// Add messages
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface UseMessageQueueOptions {
|
|||||||
isConfigInitialized: boolean;
|
isConfigInitialized: boolean;
|
||||||
streamingState: StreamingState;
|
streamingState: StreamingState;
|
||||||
submitQuery: (query: string) => void;
|
submitQuery: (query: string) => void;
|
||||||
|
isMcpReady: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseMessageQueueReturn {
|
export interface UseMessageQueueReturn {
|
||||||
@@ -30,6 +31,7 @@ export function useMessageQueue({
|
|||||||
isConfigInitialized,
|
isConfigInitialized,
|
||||||
streamingState,
|
streamingState,
|
||||||
submitQuery,
|
submitQuery,
|
||||||
|
isMcpReady,
|
||||||
}: UseMessageQueueOptions): UseMessageQueueReturn {
|
}: UseMessageQueueOptions): UseMessageQueueReturn {
|
||||||
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ export function useMessageQueue({
|
|||||||
if (
|
if (
|
||||||
isConfigInitialized &&
|
isConfigInitialized &&
|
||||||
streamingState === StreamingState.Idle &&
|
streamingState === StreamingState.Idle &&
|
||||||
|
isMcpReady &&
|
||||||
messageQueue.length > 0
|
messageQueue.length > 0
|
||||||
) {
|
) {
|
||||||
// Combine all messages with double newlines for clarity
|
// Combine all messages with double newlines for clarity
|
||||||
@@ -75,7 +78,13 @@ export function useMessageQueue({
|
|||||||
setMessageQueue([]);
|
setMessageQueue([]);
|
||||||
submitQuery(combinedMessage);
|
submitQuery(combinedMessage);
|
||||||
}
|
}
|
||||||
}, [isConfigInitialized, streamingState, messageQueue, submitQuery]);
|
}, [
|
||||||
|
isConfigInitialized,
|
||||||
|
streamingState,
|
||||||
|
isMcpReady,
|
||||||
|
messageQueue,
|
||||||
|
submitQuery,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageQueue,
|
messageQueue,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describe('useSelectionList', () => {
|
|||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
showNumbers?: boolean;
|
showNumbers?: boolean;
|
||||||
|
wrapAround?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
let hookResult: ReturnType<typeof useSelectionList>;
|
let hookResult: ReturnType<typeof useSelectionList>;
|
||||||
function TestComponent(props: typeof initialProps) {
|
function TestComponent(props: typeof initialProps) {
|
||||||
@@ -285,6 +286,39 @@ describe('useSelectionList', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Wrapping (wrapAround)', () => {
|
||||||
|
it('should wrap by default (wrapAround=true)', async () => {
|
||||||
|
const { result } = await renderSelectionListHook({
|
||||||
|
items,
|
||||||
|
initialIndex: items.length - 1,
|
||||||
|
onSelect: mockOnSelect,
|
||||||
|
});
|
||||||
|
expect(result.current.activeIndex).toBe(3);
|
||||||
|
pressKey('down');
|
||||||
|
expect(result.current.activeIndex).toBe(0);
|
||||||
|
|
||||||
|
pressKey('up');
|
||||||
|
expect(result.current.activeIndex).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not wrap when wrapAround is false', async () => {
|
||||||
|
const { result } = await renderSelectionListHook({
|
||||||
|
items,
|
||||||
|
initialIndex: items.length - 1,
|
||||||
|
onSelect: mockOnSelect,
|
||||||
|
wrapAround: false,
|
||||||
|
});
|
||||||
|
expect(result.current.activeIndex).toBe(3);
|
||||||
|
pressKey('down');
|
||||||
|
expect(result.current.activeIndex).toBe(3); // Should stay at bottom
|
||||||
|
|
||||||
|
act(() => result.current.setActiveIndex(0));
|
||||||
|
expect(result.current.activeIndex).toBe(0);
|
||||||
|
pressKey('up');
|
||||||
|
expect(result.current.activeIndex).toBe(0); // Should stay at top
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Selection (Enter)', () => {
|
describe('Selection (Enter)', () => {
|
||||||
it('should call onSelect when "return" is pressed on enabled item', async () => {
|
it('should call onSelect when "return" is pressed on enabled item', async () => {
|
||||||
await renderSelectionListHook({
|
await renderSelectionListHook({
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface UseSelectionListOptions<T> {
|
|||||||
onHighlight?: (value: T) => void;
|
onHighlight?: (value: T) => void;
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
showNumbers?: boolean;
|
showNumbers?: boolean;
|
||||||
|
wrapAround?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseSelectionListResult {
|
export interface UseSelectionListResult {
|
||||||
@@ -40,6 +41,7 @@ interface SelectionListState {
|
|||||||
pendingHighlight: boolean;
|
pendingHighlight: boolean;
|
||||||
pendingSelect: boolean;
|
pendingSelect: boolean;
|
||||||
items: BaseSelectionItem[];
|
items: BaseSelectionItem[];
|
||||||
|
wrapAround: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectionListAction =
|
type SelectionListAction =
|
||||||
@@ -60,7 +62,11 @@ type SelectionListAction =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'INITIALIZE';
|
type: 'INITIALIZE';
|
||||||
payload: { initialIndex: number; items: BaseSelectionItem[] };
|
payload: {
|
||||||
|
initialIndex: number;
|
||||||
|
items: BaseSelectionItem[];
|
||||||
|
wrapAround: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'CLEAR_PENDING_FLAGS';
|
type: 'CLEAR_PENDING_FLAGS';
|
||||||
@@ -75,6 +81,7 @@ const findNextValidIndex = (
|
|||||||
currentIndex: number,
|
currentIndex: number,
|
||||||
direction: 'up' | 'down',
|
direction: 'up' | 'down',
|
||||||
items: BaseSelectionItem[],
|
items: BaseSelectionItem[],
|
||||||
|
wrapAround = true,
|
||||||
): number => {
|
): number => {
|
||||||
const len = items.length;
|
const len = items.length;
|
||||||
if (len === 0) return currentIndex;
|
if (len === 0) return currentIndex;
|
||||||
@@ -83,13 +90,34 @@ const findNextValidIndex = (
|
|||||||
const step = direction === 'down' ? 1 : -1;
|
const step = direction === 'down' ? 1 : -1;
|
||||||
|
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
// Calculate the next index, wrapping around if necessary.
|
const candidateIndex = nextIndex + step;
|
||||||
// We add `len` before the modulo to ensure a positive result in JS for negative steps.
|
|
||||||
nextIndex = (nextIndex + step + len) % len;
|
if (wrapAround) {
|
||||||
|
// Calculate the next index, wrapping around if necessary.
|
||||||
|
// We add `len` before the modulo to ensure a positive result in JS for negative steps.
|
||||||
|
nextIndex = (candidateIndex + len) % len;
|
||||||
|
} else {
|
||||||
|
if (candidateIndex < 0 || candidateIndex >= len) {
|
||||||
|
// Out of bounds and wrapping is disabled
|
||||||
|
return currentIndex;
|
||||||
|
}
|
||||||
|
nextIndex = candidateIndex;
|
||||||
|
}
|
||||||
|
|
||||||
if (!items[nextIndex]?.disabled) {
|
if (!items[nextIndex]?.disabled) {
|
||||||
return nextIndex;
|
return nextIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!wrapAround) {
|
||||||
|
// If the item is disabled and we're not wrapping, we continue searching
|
||||||
|
// in the same direction, but we must stop if we hit the bounds.
|
||||||
|
if (
|
||||||
|
(direction === 'down' && nextIndex === len - 1) ||
|
||||||
|
(direction === 'up' && nextIndex === 0)
|
||||||
|
) {
|
||||||
|
return currentIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If all items are disabled, return the original index
|
// If all items are disabled, return the original index
|
||||||
@@ -120,7 +148,7 @@ const computeInitialIndex = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (items[targetIndex]?.disabled) {
|
if (items[targetIndex]?.disabled) {
|
||||||
const nextValid = findNextValidIndex(targetIndex, 'down', items);
|
const nextValid = findNextValidIndex(targetIndex, 'down', items, true);
|
||||||
targetIndex = nextValid;
|
targetIndex = nextValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +176,13 @@ function selectionListReducer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'MOVE_UP': {
|
case 'MOVE_UP': {
|
||||||
const { items } = state;
|
const { items, wrapAround } = state;
|
||||||
const newIndex = findNextValidIndex(state.activeIndex, 'up', items);
|
const newIndex = findNextValidIndex(
|
||||||
|
state.activeIndex,
|
||||||
|
'up',
|
||||||
|
items,
|
||||||
|
wrapAround,
|
||||||
|
);
|
||||||
if (newIndex !== state.activeIndex) {
|
if (newIndex !== state.activeIndex) {
|
||||||
return { ...state, activeIndex: newIndex, pendingHighlight: true };
|
return { ...state, activeIndex: newIndex, pendingHighlight: true };
|
||||||
}
|
}
|
||||||
@@ -157,8 +190,13 @@ function selectionListReducer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'MOVE_DOWN': {
|
case 'MOVE_DOWN': {
|
||||||
const { items } = state;
|
const { items, wrapAround } = state;
|
||||||
const newIndex = findNextValidIndex(state.activeIndex, 'down', items);
|
const newIndex = findNextValidIndex(
|
||||||
|
state.activeIndex,
|
||||||
|
'down',
|
||||||
|
items,
|
||||||
|
wrapAround,
|
||||||
|
);
|
||||||
if (newIndex !== state.activeIndex) {
|
if (newIndex !== state.activeIndex) {
|
||||||
return { ...state, activeIndex: newIndex, pendingHighlight: true };
|
return { ...state, activeIndex: newIndex, pendingHighlight: true };
|
||||||
}
|
}
|
||||||
@@ -170,7 +208,7 @@ function selectionListReducer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'INITIALIZE': {
|
case 'INITIALIZE': {
|
||||||
const { initialIndex, items } = action.payload;
|
const { initialIndex, items, wrapAround } = action.payload;
|
||||||
const activeKey =
|
const activeKey =
|
||||||
initialIndex === state.initialIndex &&
|
initialIndex === state.initialIndex &&
|
||||||
state.activeIndex !== state.initialIndex
|
state.activeIndex !== state.initialIndex
|
||||||
@@ -186,6 +224,7 @@ function selectionListReducer(
|
|||||||
initialIndex,
|
initialIndex,
|
||||||
activeIndex: targetIndex,
|
activeIndex: targetIndex,
|
||||||
pendingHighlight: false,
|
pendingHighlight: false,
|
||||||
|
wrapAround,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +284,7 @@ export function useSelectionList<T>({
|
|||||||
onHighlight,
|
onHighlight,
|
||||||
isFocused = true,
|
isFocused = true,
|
||||||
showNumbers = false,
|
showNumbers = false,
|
||||||
|
wrapAround = true,
|
||||||
}: UseSelectionListOptions<T>): UseSelectionListResult {
|
}: UseSelectionListOptions<T>): UseSelectionListResult {
|
||||||
const baseItems = toBaseItems(items);
|
const baseItems = toBaseItems(items);
|
||||||
|
|
||||||
@@ -254,12 +294,14 @@ export function useSelectionList<T>({
|
|||||||
pendingHighlight: false,
|
pendingHighlight: false,
|
||||||
pendingSelect: false,
|
pendingSelect: false,
|
||||||
items: baseItems,
|
items: baseItems,
|
||||||
|
wrapAround,
|
||||||
});
|
});
|
||||||
const numberInputRef = useRef('');
|
const numberInputRef = useRef('');
|
||||||
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
|
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const prevBaseItemsRef = useRef(baseItems);
|
const prevBaseItemsRef = useRef(baseItems);
|
||||||
const prevInitialIndexRef = useRef(initialIndex);
|
const prevInitialIndexRef = useRef(initialIndex);
|
||||||
|
const prevWrapAroundRef = useRef(wrapAround);
|
||||||
|
|
||||||
// Initialize/synchronize state when initialIndex or items change
|
// Initialize/synchronize state when initialIndex or items change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -268,14 +310,16 @@ export function useSelectionList<T>({
|
|||||||
baseItems,
|
baseItems,
|
||||||
);
|
);
|
||||||
const initialIndexChanged = prevInitialIndexRef.current !== initialIndex;
|
const initialIndexChanged = prevInitialIndexRef.current !== initialIndex;
|
||||||
|
const wrapAroundChanged = prevWrapAroundRef.current !== wrapAround;
|
||||||
|
|
||||||
if (baseItemsChanged || initialIndexChanged) {
|
if (baseItemsChanged || initialIndexChanged || wrapAroundChanged) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'INITIALIZE',
|
type: 'INITIALIZE',
|
||||||
payload: { initialIndex, items: baseItems },
|
payload: { initialIndex, items: baseItems, wrapAround },
|
||||||
});
|
});
|
||||||
prevBaseItemsRef.current = baseItems;
|
prevBaseItemsRef.current = baseItems;
|
||||||
prevInitialIndexRef.current = initialIndex;
|
prevInitialIndexRef.current = initialIndex;
|
||||||
|
prevWrapAroundRef.current = wrapAround;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ describe('useSessionResume', () => {
|
|||||||
1,
|
1,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(mockRefreshStatic).toHaveBeenCalled();
|
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
|
||||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
|
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
|
||||||
clientHistory,
|
clientHistory,
|
||||||
resumedData,
|
resumedData,
|
||||||
@@ -174,7 +174,7 @@ describe('useSessionResume', () => {
|
|||||||
|
|
||||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||||
expect(mockRefreshStatic).toHaveBeenCalled();
|
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
|
||||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData);
|
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -338,6 +338,7 @@ describe('useSessionResume', () => {
|
|||||||
1,
|
1,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
|
||||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
|
expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
REFERENCE_CONTENT_START,
|
||||||
|
REFERENCE_CONTENT_END,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
export const formatMemoryUsage = (bytes: number): string => {
|
export const formatMemoryUsage = (bytes: number): string => {
|
||||||
const gb = bytes / (1024 * 1024 * 1024);
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
if (bytes < 1024 * 1024) {
|
if (bytes < 1024 * 1024) {
|
||||||
@@ -76,12 +81,9 @@ export const formatTimeAgo = (date: string | number | Date): string => {
|
|||||||
return `${formatDuration(diffMs)} ago`;
|
return `${formatDuration(diffMs)} ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
|
|
||||||
const REFERENCE_CONTENT_END = '--- End of content ---';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes content bounded by reference content markers from the given text.
|
* Removes content bounded by reference content markers from the given text.
|
||||||
* The markers are "--- Content from referenced files ---" and "--- End of content ---".
|
* The markers are "${REFERENCE_CONTENT_START}" and "${REFERENCE_CONTENT_END}".
|
||||||
*
|
*
|
||||||
* @param text The input text containing potential reference blocks.
|
* @param text The input text containing potential reference blocks.
|
||||||
* @returns The text with reference blocks removed and trimmed.
|
* @returns The text with reference blocks removed and trimmed.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
debugLogger,
|
debugLogger,
|
||||||
ReadManyFilesTool,
|
ReadManyFilesTool,
|
||||||
|
REFERENCE_CONTENT_START,
|
||||||
resolveModel,
|
resolveModel,
|
||||||
createWorkingStdio,
|
createWorkingStdio,
|
||||||
startupProfiler,
|
startupProfiler,
|
||||||
@@ -817,7 +818,7 @@ export class Session {
|
|||||||
if (Array.isArray(result.llmContent)) {
|
if (Array.isArray(result.llmContent)) {
|
||||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||||
processedQueryParts.push({
|
processedQueryParts.push({
|
||||||
text: '\n--- Content from referenced files ---',
|
text: `\n${REFERENCE_CONTENT_START}`,
|
||||||
});
|
});
|
||||||
for (const part of result.llmContent) {
|
for (const part of result.llmContent) {
|
||||||
if (typeof part === 'string') {
|
if (typeof part === 'string') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@google/gemini-cli-core",
|
"name": "@google/gemini-cli-core",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"description": "Gemini CLI Core",
|
"description": "Gemini CLI Core",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export class Config {
|
|||||||
this.planEnabled = params.plan ?? false;
|
this.planEnabled = params.plan ?? false;
|
||||||
this.enableEventDrivenScheduler =
|
this.enableEventDrivenScheduler =
|
||||||
params.enableEventDrivenScheduler ?? false;
|
params.enableEventDrivenScheduler ?? false;
|
||||||
this.skillsSupport = params.skillsSupport ?? false;
|
this.skillsSupport = params.skillsSupport ?? true;
|
||||||
this.disabledSkills = params.disabledSkills ?? [];
|
this.disabledSkills = params.disabledSkills ?? [];
|
||||||
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
|
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
|
||||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||||
@@ -682,7 +682,7 @@ export class Config {
|
|||||||
? false
|
? false
|
||||||
: (params.useWriteTodos ?? true);
|
: (params.useWriteTodos ?? true);
|
||||||
this.enableHooksUI = params.enableHooksUI ?? true;
|
this.enableHooksUI = params.enableHooksUI ?? true;
|
||||||
this.enableHooks = params.enableHooks ?? false;
|
this.enableHooks = params.enableHooks ?? true;
|
||||||
this.disabledHooks = params.disabledHooks ?? [];
|
this.disabledHooks = params.disabledHooks ?? [];
|
||||||
|
|
||||||
this.codebaseInvestigatorSettings = {
|
this.codebaseInvestigatorSettings = {
|
||||||
|
|||||||
@@ -3107,6 +3107,7 @@ ${JSON.stringify(
|
|||||||
mockHookSystem.fireAfterAgentEvent.mockResolvedValue({
|
mockHookSystem.fireAfterAgentEvent.mockResolvedValue({
|
||||||
shouldStopExecution: () => true,
|
shouldStopExecution: () => true,
|
||||||
getEffectiveReason: () => 'Stopped after agent',
|
getEffectiveReason: () => 'Stopped after agent',
|
||||||
|
shouldClearContext: () => false,
|
||||||
systemMessage: undefined,
|
systemMessage: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3121,10 +3122,12 @@ ${JSON.stringify(
|
|||||||
);
|
);
|
||||||
const events = await fromAsync(stream);
|
const events = await fromAsync(stream);
|
||||||
|
|
||||||
expect(events).toContainEqual({
|
expect(events).toContainEqual(
|
||||||
type: GeminiEventType.AgentExecutionStopped,
|
expect.objectContaining({
|
||||||
value: { reason: 'Stopped after agent' },
|
type: GeminiEventType.AgentExecutionStopped,
|
||||||
});
|
value: expect.objectContaining({ reason: 'Stopped after agent' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
// sendMessageStream should not recurse
|
// sendMessageStream should not recurse
|
||||||
expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
|
expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -3135,11 +3138,60 @@ ${JSON.stringify(
|
|||||||
shouldStopExecution: () => false,
|
shouldStopExecution: () => false,
|
||||||
isBlockingDecision: () => true,
|
isBlockingDecision: () => true,
|
||||||
getEffectiveReason: () => 'Please explain',
|
getEffectiveReason: () => 'Please explain',
|
||||||
|
shouldClearContext: () => false,
|
||||||
systemMessage: undefined,
|
systemMessage: undefined,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
shouldStopExecution: () => false,
|
shouldStopExecution: () => false,
|
||||||
isBlockingDecision: () => false,
|
isBlockingDecision: () => false,
|
||||||
|
shouldClearContext: () => false,
|
||||||
|
systemMessage: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockTurnRunFn.mockImplementation(async function* () {
|
||||||
|
yield { type: GeminiEventType.Content, value: 'Response' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = client.sendMessageStream(
|
||||||
|
{ text: 'Hi' },
|
||||||
|
new AbortController().signal,
|
||||||
|
'test-prompt',
|
||||||
|
);
|
||||||
|
const events = await fromAsync(stream);
|
||||||
|
|
||||||
|
expect(events).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: GeminiEventType.AgentExecutionBlocked,
|
||||||
|
value: expect.objectContaining({ reason: 'Please explain' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Should have called turn run twice (original + re-prompt)
|
||||||
|
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.anything(),
|
||||||
|
[{ text: 'Please explain' }],
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call resetChat when AfterAgent hook returns shouldClearContext: true', async () => {
|
||||||
|
const resetChatSpy = vi
|
||||||
|
.spyOn(client, 'resetChat')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
mockHookSystem.fireAfterAgentEvent
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
shouldStopExecution: () => false,
|
||||||
|
isBlockingDecision: () => true,
|
||||||
|
getEffectiveReason: () => 'Blocked and clearing context',
|
||||||
|
shouldClearContext: () => true,
|
||||||
|
systemMessage: undefined,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
shouldStopExecution: () => false,
|
||||||
|
isBlockingDecision: () => false,
|
||||||
|
shouldClearContext: () => false,
|
||||||
systemMessage: undefined,
|
systemMessage: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3156,16 +3208,15 @@ ${JSON.stringify(
|
|||||||
|
|
||||||
expect(events).toContainEqual({
|
expect(events).toContainEqual({
|
||||||
type: GeminiEventType.AgentExecutionBlocked,
|
type: GeminiEventType.AgentExecutionBlocked,
|
||||||
value: { reason: 'Please explain' },
|
value: {
|
||||||
|
reason: 'Blocked and clearing context',
|
||||||
|
systemMessage: undefined,
|
||||||
|
contextCleared: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// Should have called turn run twice (original + re-prompt)
|
expect(resetChatSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
|
resetChatSpy.mockRestore();
|
||||||
2,
|
|
||||||
expect.anything(),
|
|
||||||
[{ text: 'Please explain' }],
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ import {
|
|||||||
logContentRetryFailure,
|
logContentRetryFailure,
|
||||||
logNextSpeakerCheck,
|
logNextSpeakerCheck,
|
||||||
} from '../telemetry/loggers.js';
|
} from '../telemetry/loggers.js';
|
||||||
import type { DefaultHookOutput } from '../hooks/types.js';
|
import type {
|
||||||
|
DefaultHookOutput,
|
||||||
|
AfterAgentHookOutput,
|
||||||
|
} from '../hooks/types.js';
|
||||||
import {
|
import {
|
||||||
ContentRetryFailureEvent,
|
ContentRetryFailureEvent,
|
||||||
NextSpeakerCheckEvent,
|
NextSpeakerCheckEvent,
|
||||||
@@ -812,26 +815,41 @@ export class GeminiClient {
|
|||||||
turn,
|
turn,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hookOutput?.shouldStopExecution()) {
|
// Cast to AfterAgentHookOutput for access to shouldClearContext()
|
||||||
|
const afterAgentOutput = hookOutput as AfterAgentHookOutput | undefined;
|
||||||
|
|
||||||
|
if (afterAgentOutput?.shouldStopExecution()) {
|
||||||
|
const contextCleared = afterAgentOutput.shouldClearContext();
|
||||||
yield {
|
yield {
|
||||||
type: GeminiEventType.AgentExecutionStopped,
|
type: GeminiEventType.AgentExecutionStopped,
|
||||||
value: {
|
value: {
|
||||||
reason: hookOutput.getEffectiveReason(),
|
reason: afterAgentOutput.getEffectiveReason(),
|
||||||
systemMessage: hookOutput.systemMessage,
|
systemMessage: afterAgentOutput.systemMessage,
|
||||||
|
contextCleared,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Clear context if requested (honor both stop + clear)
|
||||||
|
if (contextCleared) {
|
||||||
|
await this.resetChat();
|
||||||
|
}
|
||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hookOutput?.isBlockingDecision()) {
|
if (afterAgentOutput?.isBlockingDecision()) {
|
||||||
const continueReason = hookOutput.getEffectiveReason();
|
const continueReason = afterAgentOutput.getEffectiveReason();
|
||||||
|
const contextCleared = afterAgentOutput.shouldClearContext();
|
||||||
yield {
|
yield {
|
||||||
type: GeminiEventType.AgentExecutionBlocked,
|
type: GeminiEventType.AgentExecutionBlocked,
|
||||||
value: {
|
value: {
|
||||||
reason: continueReason,
|
reason: continueReason,
|
||||||
systemMessage: hookOutput.systemMessage,
|
systemMessage: afterAgentOutput.systemMessage,
|
||||||
|
contextCleared,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Clear context if requested
|
||||||
|
if (contextCleared) {
|
||||||
|
await this.resetChat();
|
||||||
|
}
|
||||||
const continueRequest = [{ text: continueReason }];
|
const continueRequest = [{ text: continueReason }];
|
||||||
yield* this.sendMessageStream(
|
yield* this.sendMessageStream(
|
||||||
continueRequest,
|
continueRequest,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export type ServerGeminiAgentExecutionStoppedEvent = {
|
|||||||
value: {
|
value: {
|
||||||
reason: string;
|
reason: string;
|
||||||
systemMessage?: string;
|
systemMessage?: string;
|
||||||
|
contextCleared?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export type ServerGeminiAgentExecutionBlockedEvent = {
|
|||||||
value: {
|
value: {
|
||||||
reason: string;
|
reason: string;
|
||||||
systemMessage?: string;
|
systemMessage?: string;
|
||||||
|
contextCleared?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
BeforeModelHookOutput,
|
BeforeModelHookOutput,
|
||||||
BeforeToolSelectionHookOutput,
|
BeforeToolSelectionHookOutput,
|
||||||
AfterModelHookOutput,
|
AfterModelHookOutput,
|
||||||
|
AfterAgentHookOutput,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { HookEventName } from './types.js';
|
import { HookEventName } from './types.js';
|
||||||
|
|
||||||
@@ -158,11 +159,21 @@ export class HookAggregator {
|
|||||||
merged.suppressOutput = true;
|
merged.suppressOutput = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge hookSpecificOutput
|
// Handle clearContext (any true wins) - for AfterAgent hooks
|
||||||
if (output.hookSpecificOutput) {
|
if (output.hookSpecificOutput?.['clearContext'] === true) {
|
||||||
merged.hookSpecificOutput = {
|
merged.hookSpecificOutput = {
|
||||||
...(merged.hookSpecificOutput || {}),
|
...(merged.hookSpecificOutput || {}),
|
||||||
...output.hookSpecificOutput,
|
clearContext: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge hookSpecificOutput (excluding clearContext which is handled above)
|
||||||
|
if (output.hookSpecificOutput) {
|
||||||
|
const { clearContext: _clearContext, ...restSpecificOutput } =
|
||||||
|
output.hookSpecificOutput;
|
||||||
|
merged.hookSpecificOutput = {
|
||||||
|
...(merged.hookSpecificOutput || {}),
|
||||||
|
...restSpecificOutput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +334,8 @@ export class HookAggregator {
|
|||||||
return new BeforeToolSelectionHookOutput(output);
|
return new BeforeToolSelectionHookOutput(output);
|
||||||
case HookEventName.AfterModel:
|
case HookEventName.AfterModel:
|
||||||
return new AfterModelHookOutput(output);
|
return new AfterModelHookOutput(output);
|
||||||
|
case HookEventName.AfterAgent:
|
||||||
|
return new AfterAgentHookOutput(output);
|
||||||
default:
|
default:
|
||||||
return new DefaultHookOutput(output);
|
return new DefaultHookOutput(output);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ export function createHookOutput(
|
|||||||
return new BeforeToolSelectionHookOutput(data);
|
return new BeforeToolSelectionHookOutput(data);
|
||||||
case 'BeforeTool':
|
case 'BeforeTool':
|
||||||
return new BeforeToolHookOutput(data);
|
return new BeforeToolHookOutput(data);
|
||||||
|
case 'AfterAgent':
|
||||||
|
return new AfterAgentHookOutput(data);
|
||||||
default:
|
default:
|
||||||
return new DefaultHookOutput(data);
|
return new DefaultHookOutput(data);
|
||||||
}
|
}
|
||||||
@@ -238,6 +240,13 @@ export class DefaultHookOutput implements HookOutput {
|
|||||||
}
|
}
|
||||||
return { blocked: false, reason: '' };
|
return { blocked: false, reason: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if context clearing was requested by hook.
|
||||||
|
*/
|
||||||
|
shouldClearContext(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -362,6 +371,21 @@ export class AfterModelHookOutput extends DefaultHookOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specific hook output class for AfterAgent events
|
||||||
|
*/
|
||||||
|
export class AfterAgentHookOutput extends DefaultHookOutput {
|
||||||
|
/**
|
||||||
|
* Check if context clearing was requested by hook
|
||||||
|
*/
|
||||||
|
override shouldClearContext(): boolean {
|
||||||
|
if (this.hookSpecificOutput && 'clearContext' in this.hookSpecificOutput) {
|
||||||
|
return this.hookSpecificOutput['clearContext'] === true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context for MCP tool executions.
|
* Context for MCP tool executions.
|
||||||
* Contains non-sensitive connection information about the MCP server
|
* Contains non-sensitive connection information about the MCP server
|
||||||
@@ -475,6 +499,16 @@ export interface AfterAgentInput extends HookInput {
|
|||||||
stop_hook_active: boolean;
|
stop_hook_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AfterAgent hook output
|
||||||
|
*/
|
||||||
|
export interface AfterAgentOutput extends HookOutput {
|
||||||
|
hookSpecificOutput?: {
|
||||||
|
hookEventName: 'AfterAgent';
|
||||||
|
clearContext?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SessionStart source types
|
* SessionStart source types
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export * from './utils/checkpointUtils.js';
|
|||||||
export * from './utils/secure-browser-launcher.js';
|
export * from './utils/secure-browser-launcher.js';
|
||||||
export * from './utils/apiConversionUtils.js';
|
export * from './utils/apiConversionUtils.js';
|
||||||
export * from './utils/channel.js';
|
export * from './utils/channel.js';
|
||||||
|
export * from './utils/constants.js';
|
||||||
|
|
||||||
// Export services
|
// Export services
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export class McpClientManager {
|
|||||||
if (currentPromise === this.discoveryPromise) {
|
if (currentPromise === this.discoveryPromise) {
|
||||||
this.discoveryPromise = undefined;
|
this.discoveryPromise = undefined;
|
||||||
this.discoveryState = MCPDiscoveryState.COMPLETED;
|
this.discoveryState = MCPDiscoveryState.COMPLETED;
|
||||||
|
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {}); // Prevents unhandled rejection from the .finally branch
|
.catch(() => {}); // Prevents unhandled rejection from the .finally branch
|
||||||
@@ -273,6 +274,12 @@ export class McpClientManager {
|
|||||||
this.cliConfig.getMcpServerCommand(),
|
this.cliConfig.getMcpServerCommand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (Object.keys(servers).length === 0) {
|
||||||
|
this.discoveryState = MCPDiscoveryState.COMPLETED;
|
||||||
|
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.entries(servers).map(([name, config]) =>
|
Object.entries(servers).map(([name, config]) =>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import { FileOperationEvent } from '../telemetry/types.js';
|
|||||||
import { ToolErrorType } from './tool-error.js';
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import { READ_MANY_FILES_TOOL_NAME } from './tool-names.js';
|
import { READ_MANY_FILES_TOOL_NAME } from './tool-names.js';
|
||||||
|
|
||||||
|
import { REFERENCE_CONTENT_END } from '../utils/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for the ReadManyFilesTool.
|
* Parameters for the ReadManyFilesTool.
|
||||||
*/
|
*/
|
||||||
@@ -98,7 +100,7 @@ function getDefaultExcludes(config?: Config): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
|
const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
|
||||||
const DEFAULT_OUTPUT_TERMINATOR = '\n--- End of content ---';
|
const DEFAULT_OUTPUT_TERMINATOR = `\n${REFERENCE_CONTENT_END}`;
|
||||||
|
|
||||||
class ReadManyFilesToolInvocation extends BaseToolInvocation<
|
class ReadManyFilesToolInvocation extends BaseToolInvocation<
|
||||||
ReadManyFilesParams,
|
ReadManyFilesParams,
|
||||||
@@ -517,7 +519,7 @@ This tool is useful when you need to understand or analyze a collection of files
|
|||||||
- Gathering context from multiple configuration files.
|
- Gathering context from multiple configuration files.
|
||||||
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
|
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
|
||||||
|
|
||||||
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '--- End of content ---' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
|
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '${REFERENCE_CONTENT_END}' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
|
||||||
Kind.Read,
|
Kind.Read,
|
||||||
parameterSchema,
|
parameterSchema,
|
||||||
messageBus,
|
messageBus,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
|
||||||
|
export const REFERENCE_CONTENT_END = '--- End of content ---';
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { McpClient } from '../tools/mcp-client.js';
|
||||||
|
import type { ExtensionEvents } from './extensionLoader.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the severity level for user-facing feedback.
|
* Defines the severity level for user-facing feedback.
|
||||||
@@ -115,6 +117,7 @@ export enum CoreEvent {
|
|||||||
Output = 'output',
|
Output = 'output',
|
||||||
MemoryChanged = 'memory-changed',
|
MemoryChanged = 'memory-changed',
|
||||||
ExternalEditorClosed = 'external-editor-closed',
|
ExternalEditorClosed = 'external-editor-closed',
|
||||||
|
McpClientUpdate = 'mcp-client-update',
|
||||||
SettingsChanged = 'settings-changed',
|
SettingsChanged = 'settings-changed',
|
||||||
HookStart = 'hook-start',
|
HookStart = 'hook-start',
|
||||||
HookEnd = 'hook-end',
|
HookEnd = 'hook-end',
|
||||||
@@ -123,13 +126,14 @@ export enum CoreEvent {
|
|||||||
RetryAttempt = 'retry-attempt',
|
RetryAttempt = 'retry-attempt',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoreEvents {
|
export interface CoreEvents extends ExtensionEvents {
|
||||||
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
|
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
|
||||||
[CoreEvent.ModelChanged]: [ModelChangedPayload];
|
[CoreEvent.ModelChanged]: [ModelChangedPayload];
|
||||||
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
|
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
|
||||||
[CoreEvent.Output]: [OutputPayload];
|
[CoreEvent.Output]: [OutputPayload];
|
||||||
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
|
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
|
||||||
[CoreEvent.ExternalEditorClosed]: never[];
|
[CoreEvent.ExternalEditorClosed]: never[];
|
||||||
|
[CoreEvent.McpClientUpdate]: Array<Map<string, McpClient> | never>;
|
||||||
[CoreEvent.SettingsChanged]: never[];
|
[CoreEvent.SettingsChanged]: never[];
|
||||||
[CoreEvent.HookStart]: [HookStartPayload];
|
[CoreEvent.HookStart]: [HookStartPayload];
|
||||||
[CoreEvent.HookEnd]: [HookEndPayload];
|
[CoreEvent.HookEnd]: [HookEndPayload];
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { getPackageJson } from './package.js';
|
||||||
|
import { readPackageUp } from 'read-package-up';
|
||||||
|
|
||||||
|
vi.mock('read-package-up', () => ({
|
||||||
|
readPackageUp: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('getPackageJson', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return packageJson when found', async () => {
|
||||||
|
const expectedPackageJsonResult = { name: 'test-pkg', version: '1.2.3' };
|
||||||
|
vi.mocked(readPackageUp).mockResolvedValue({
|
||||||
|
packageJson: expectedPackageJsonResult,
|
||||||
|
path: '/path/to/package.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPackageJson('/some/path');
|
||||||
|
expect(result).toEqual(expectedPackageJsonResult);
|
||||||
|
expect(readPackageUp).toHaveBeenCalledWith({
|
||||||
|
cwd: '/some/path',
|
||||||
|
normalize: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
description: 'no package.json is found',
|
||||||
|
setup: () => vi.mocked(readPackageUp).mockResolvedValue(undefined),
|
||||||
|
expected: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'non-semver versions (when normalize is false)',
|
||||||
|
setup: () =>
|
||||||
|
vi.mocked(readPackageUp).mockResolvedValue({
|
||||||
|
packageJson: { name: 'test-pkg', version: '2024.60' },
|
||||||
|
path: '/path/to/package.json',
|
||||||
|
}),
|
||||||
|
expected: { name: 'test-pkg', version: '2024.60' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'readPackageUp throws',
|
||||||
|
setup: () =>
|
||||||
|
vi.mocked(readPackageUp).mockRejectedValue(new Error('Read error')),
|
||||||
|
expected: undefined,
|
||||||
|
},
|
||||||
|
])('should handle $description', async ({ setup, expected }) => {
|
||||||
|
setup();
|
||||||
|
const result = await getPackageJson('/some/path');
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
readPackageUp,
|
readPackageUp,
|
||||||
type PackageJson as BasePackageJson,
|
type PackageJson as BasePackageJson,
|
||||||
} from 'read-package-up';
|
} from 'read-package-up';
|
||||||
|
import { debugLogger } from './debugLogger.js';
|
||||||
|
|
||||||
export type PackageJson = BasePackageJson & {
|
export type PackageJson = BasePackageJson & {
|
||||||
config?: {
|
config?: {
|
||||||
@@ -32,10 +33,15 @@ export type PackageJson = BasePackageJson & {
|
|||||||
export async function getPackageJson(
|
export async function getPackageJson(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
): Promise<PackageJson | undefined> {
|
): Promise<PackageJson | undefined> {
|
||||||
const result = await readPackageUp({ cwd });
|
try {
|
||||||
if (!result) {
|
const result = await readPackageUp({ cwd, normalize: false });
|
||||||
|
if (!result) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.packageJson;
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Error occurred while reading package.json', error);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.packageJson;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@google/gemini-cli-test-utils",
|
"name": "@google/gemini-cli-test-utils",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "gemini-cli-vscode-ide-companion",
|
"name": "gemini-cli-vscode-ide-companion",
|
||||||
"displayName": "Gemini CLI Companion",
|
"displayName": "Gemini CLI Companion",
|
||||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
"version": "0.26.0",
|
||||||
"publisher": "google",
|
"publisher": "google",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1422,9 +1422,9 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Agent Skills",
|
"title": "Agent Skills (Deprecated)",
|
||||||
"description": "Enable Agent Skills (experimental).",
|
"description": "[Deprecated] Enable Agent Skills (experimental).",
|
||||||
"markdownDescription": "Enable Agent Skills (experimental).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
|
"markdownDescription": "[Deprecated] Enable Agent Skills (experimental).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
|
||||||
"default": false,
|
"default": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -1574,8 +1574,8 @@
|
|||||||
"enabled": {
|
"enabled": {
|
||||||
"title": "Enable Hooks",
|
"title": "Enable Hooks",
|
||||||
"description": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.",
|
"description": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.",
|
||||||
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `false`",
|
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`",
|
||||||
"default": false,
|
"default": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"disabled": {
|
"disabled": {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md'];
|
|||||||
const KEY_NAME_OVERRIDES: Record<string, string> = {
|
const KEY_NAME_OVERRIDES: Record<string, string> = {
|
||||||
return: 'Enter',
|
return: 'Enter',
|
||||||
escape: 'Esc',
|
escape: 'Esc',
|
||||||
|
'double escape': 'Double Esc',
|
||||||
tab: 'Tab',
|
tab: 'Tab',
|
||||||
backspace: 'Backspace',
|
backspace: 'Backspace',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
|
|||||||
Reference in New Issue
Block a user