Skip to content

Commit 7d78f79

Browse files
[ES|QL] Code completion driven by AI (#256857)
## Summary LLM support from the editor Ghost placeholder when moving to a new empty line <img width="531" height="104" alt="image" src="https://github.com/user-attachments/assets/eb854d84-d6c5-4585-b473-b0c03a32f8a0" /> Placeholder when there is no query <img width="1071" height="219" alt="image" src="https://github.com/user-attachments/assets/f3ec9c7b-250f-442f-b287-67c1c7452f6a" /> Ghost hint when there is a comment <img width="756" height="237" alt="image" src="https://github.com/user-attachments/assets/632fe85c-6d03-48b0-a9da-dce6efe7bf14" /> Ghost hint while generating <img width="579" height="313" alt="image" src="https://github.com/user-attachments/assets/2beafc56-99de-4e6c-b8f7-034434ee38da" /> Undo / Keep functionality (only addition, no replacement) <img width="781" height="224" alt="image" src="https://github.com/user-attachments/assets/d742b876-fc0e-4ba9-ad19-c11ae9c72c18" /> Undo / Replace functionality (with replacement) <img width="1386" height="204" alt="image" src="https://github.com/user-attachments/assets/1149c428-7751-4f0f-bcd1-1c7be86dcc58" /> ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 9595128 commit 7d78f79

22 files changed

Lines changed: 1967 additions & 19 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Comment-to-ES|QL
2+
3+
Generates ES|QL code from natural-language comments in the editor using an LLM.
4+
5+
## How it works
6+
7+
1. The user writes a `//` comment on its own line describing what they want
8+
2. Presses **⌘+J** (Mac) / **Ctrl+J** (Windows/Linux)
9+
3. The comment is sent to the `/internal/esql/nl_to_esql` route
10+
4. Generated code appears below the comment highlighted in green or orange
11+
5. The user keeps or undoes the change via buttons or keyboard shortcuts
12+
13+
## License gating
14+
15+
The whole feature is gated behind an **active Enterprise license** via `useNlToEsqlCheck`. When the check fails:
16+
17+
- The editor placeholder falls back to the basic `Start typing ES|QL` text instead of advertising **⌘+J**.
18+
- The ghost-line hints (see below) never appear.
19+
- Pressing **⌘+J** is a no-op — `generateFromComment` returns early.
20+
21+
## Ghost-line hints
22+
23+
After a short pause (400 ms), an inline italic dimmed hint appears next to the cursor:
24+
25+
| When the cursor is on… | Hint shown |
26+
| --- | --- |
27+
| an empty line in a non-empty editor | `Type // and press ⌘+J to ask AI to add a step` |
28+
| a `//` line | `Press ⌘+J to generate` |
29+
30+
Hints disappear on cursor move or edit, and stay hidden while a review is open or a generation is running. The empty-editor case is excluded — the editor's own placeholder covers it.
31+
32+
## Generating indicator
33+
34+
Between **⌘+J** and the LLM response, an inline `Generating…` text shows at the end of the comment line, italic, dimmed, gently pulsing. It follows the comment line if the user edits content above during the wait, and is cleared on success, abort, error, or retrigger.
35+
36+
## Cancellation and retrigger
37+
38+
Pressing **⌘+J** while a request is in flight or a review is pending discards the previous state — the in-flight request is aborted, decorations are cleared, the review widget is dismissed. The user can iterate freely.
39+
40+
## Completion vs full-query generation
41+
42+
The route picks one of two LLM paths based on whether the editor already has ES|QL code. In both cases the comment stays in place and the generated code is inserted on a new line below it.
43+
44+
### Full-query generation
45+
46+
When the editor contains **only** a comment (no existing ES|QL code), the LLM receives the comment as a free-form instruction and produces a complete ES|QL query from scratch.
47+
48+
Example — editor contains:
49+
50+
```
51+
// Show the top 10 destinations by flight count
52+
```
53+
54+
The full generated query is inserted below the comment.
55+
56+
### Completion
57+
58+
When the editor already contains ES|QL code alongside the comment, the system operates in **completion** mode:
59+
60+
- Only the comment line is sent as the instruction; the full query is sent as context
61+
- The target comment is marked with `>>>` / `<<<` delimiters so the LLM knows which comment to act on (other comments are treated as documentation)
62+
- The LLM generates only the pipe(s) that should replace the comment, not the full query
63+
- The generated code is inserted on a new line below the comment
64+
65+
Example — editor contains:
66+
67+
```
68+
FROM kibana_sample_data_flights
69+
// Filter for delayed flights
70+
| STATS avg_delay = AVG(FlightDelayMin) BY Dest
71+
```
72+
73+
The LLM receives:
74+
75+
```
76+
FROM kibana_sample_data_flights
77+
>>> // Filter for delayed flights <<<
78+
| STATS avg_delay = AVG(FlightDelayMin) BY Dest
79+
```
80+
81+
And outputs only: `| WHERE FlightDelay == true`
82+
83+
## LLM-signaled replacement
84+
85+
The LLM decides whether its output should **replace** the pipe immediately after the comment or be **inserted** alongside it. It signals this via a `REPLACES_NEXT: true/false` flag in its response.
86+
87+
- `REPLACES_NEXT: true` — the generated code is a modified version of the next pipe (e.g., `// Group by host` modifying an existing `| STATS count = COUNT(*)`). On accept, the original pipe is removed.
88+
- `REPLACES_NEXT: false` — the generated code is new and should be inserted without touching existing pipes (e.g., `// filter for delayed flights` adding a new `| WHERE`).
89+
90+
This avoids both problems: no silent deletion of unrelated pipes (like WHERE), and no duplicate pipes left behind when the intent is clearly a modification.
91+
92+
## Review flow
93+
94+
A ViewZone + ContentWidget hybrid renders action buttons between editor lines without overlapping content.
95+
96+
### Insert mode (`REPLACES_NEXT: false`)
97+
98+
- **Generated code**: green background
99+
- **Comment**: no decoration (stays as-is for iteration)
100+
- Buttons: **Undo** (white, grey outline) / **Keep** (soft green pill)
101+
102+
### Replace mode (`REPLACES_NEXT: true`)
103+
104+
- **Generated code**: green background
105+
- **Replaced line**: amber/warning background with strikethrough
106+
- **Comment**: no decoration
107+
- Buttons: **Undo** (white, grey outline) / **Replace** (soft green pill)
108+
109+
Both buttons fill into a stronger color on hover. They sit ~8 px below the inserted code so they don't crowd the line above.
110+
111+
### Actions
112+
113+
- **Keep / Replace** (⌘⇧↵ / Ctrl+Shift+Enter): keeps the generated code. In replace mode, also removes the original pipe. The comment stays in place so the user can iterate — tweak the instruction and press ⌘+J again.
114+
- **Undo** (⌘⇧⌫ / Ctrl+Shift+Backspace): removes the generated code and restores the original state.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { css } from '@emotion/react';
11+
import { useEuiTheme } from '@elastic/eui';
12+
import { useMemo } from 'react';
13+
import { i18n } from '@kbn/i18n';
14+
15+
export const CODE_ADDED_CLASS = 'esqlCodeAdded';
16+
export const LINE_REPLACED_CLASS = 'esqlLineReplaced';
17+
export const GENERATING_HINT_CLASS = 'esqlGeneratingHint';
18+
19+
const GENERATING_TEXT = i18n.translate('esqlEditor.commentToEsql.generating', {
20+
defaultMessage: 'Generating...',
21+
});
22+
23+
export const useCommentToEsqlStyle = () => {
24+
const { euiTheme } = useEuiTheme();
25+
26+
return useMemo(
27+
() => css`
28+
.${CODE_ADDED_CLASS} {
29+
background-color: ${euiTheme.colors.backgroundLightSuccess};
30+
}
31+
.${LINE_REPLACED_CLASS} {
32+
background-color: ${euiTheme.colors.backgroundLightWarning};
33+
text-decoration: line-through;
34+
}
35+
@keyframes esqlGeneratingPulse {
36+
0%,
37+
100% {
38+
opacity: 0.4;
39+
}
40+
50% {
41+
opacity: 0.75;
42+
}
43+
}
44+
.${GENERATING_HINT_CLASS}::after {
45+
content: ${JSON.stringify(' ' + GENERATING_TEXT)};
46+
font-style: italic;
47+
color: ${euiTheme.colors.textSubdued};
48+
animation: esqlGeneratingPulse 1.4s ease-in-out infinite;
49+
}
50+
`,
51+
[
52+
euiTheme.colors.backgroundLightSuccess,
53+
euiTheme.colors.backgroundLightWarning,
54+
euiTheme.colors.textSubdued,
55+
]
56+
);
57+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
export { useCommentToEsql } from './use_comment_to_esql';
11+
export { useGhostLineHint } from './use_ghost_line_hint';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { useEuiTheme } from '@elastic/eui';
11+
import { renderHook } from '@testing-library/react';
12+
import type { monaco } from '@kbn/code-editor';
13+
import { ReviewActionsWidget } from './review_actions_widget';
14+
15+
const buildEditor = () =>
16+
({
17+
changeViewZones: jest.fn((cb: (accessor: monaco.editor.IViewZoneChangeAccessor) => void) => {
18+
cb({
19+
addZone: jest.fn(() => 'zone-id'),
20+
removeZone: jest.fn(),
21+
layoutZone: jest.fn(),
22+
});
23+
}),
24+
addContentWidget: jest.fn(),
25+
removeContentWidget: jest.fn(),
26+
} as unknown as monaco.editor.ICodeEditor);
27+
28+
describe('ReviewActionsWidget', () => {
29+
// Outside an EuiProvider, useEuiTheme returns the Amsterdam defaults — enough
30+
// for the widget's constructor to build its DOM without us hand-rolling tokens.
31+
const { result } = renderHook(() => useEuiTheme());
32+
const euiTheme = result.current.euiTheme;
33+
34+
it('invokes the matching callback when each button is clicked', () => {
35+
const onAccept = jest.fn();
36+
const onReject = jest.fn();
37+
38+
const widget = new ReviewActionsWidget(euiTheme, buildEditor(), 1, { onAccept, onReject });
39+
const dom = widget.getDomNode();
40+
const [rejectBtn, acceptBtn] = Array.from(dom.querySelectorAll('button'));
41+
42+
rejectBtn.click();
43+
expect(onReject).toHaveBeenCalledTimes(1);
44+
expect(onAccept).not.toHaveBeenCalled();
45+
46+
acceptBtn.click();
47+
expect(onAccept).toHaveBeenCalledTimes(1);
48+
expect(onReject).toHaveBeenCalledTimes(1);
49+
});
50+
51+
it('labels the accept button "Replace" when isReplaceMode is true and "Keep" otherwise', () => {
52+
const callbacks = { onAccept: jest.fn(), onReject: jest.fn() };
53+
54+
const keepWidget = new ReviewActionsWidget(euiTheme, buildEditor(), 1, callbacks, false);
55+
const keepButtons = Array.from(keepWidget.getDomNode().querySelectorAll('button'));
56+
expect(keepButtons[1].textContent).toMatch(/^Keep/);
57+
58+
const replaceWidget = new ReviewActionsWidget(euiTheme, buildEditor(), 1, callbacks, true);
59+
const replaceButtons = Array.from(replaceWidget.getDomNode().querySelectorAll('button'));
60+
expect(replaceButtons[1].textContent).toMatch(/^Replace/);
61+
});
62+
});

0 commit comments

Comments
 (0)