@theia/ai-chat-ui 1.76.0-next.7 → 1.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/lib/browser/ai-chat-ui-frontend-module.d.ts.map +1 -1
  2. package/lib/browser/ai-chat-ui-frontend-module.js +6 -0
  3. package/lib/browser/ai-chat-ui-frontend-module.js.map +1 -1
  4. package/lib/browser/chat-find/chat-find-contribution.d.ts +18 -0
  5. package/lib/browser/chat-find/chat-find-contribution.d.ts.map +1 -0
  6. package/lib/browser/chat-find/chat-find-contribution.js +76 -0
  7. package/lib/browser/chat-find/chat-find-contribution.js.map +1 -0
  8. package/lib/browser/chat-find/chat-find-highlighter.d.ts +114 -0
  9. package/lib/browser/chat-find/chat-find-highlighter.d.ts.map +1 -0
  10. package/lib/browser/chat-find/chat-find-highlighter.js +353 -0
  11. package/lib/browser/chat-find/chat-find-highlighter.js.map +1 -0
  12. package/lib/browser/chat-find/chat-find-highlighter.spec.d.ts +2 -0
  13. package/lib/browser/chat-find/chat-find-highlighter.spec.d.ts.map +1 -0
  14. package/lib/browser/chat-find/chat-find-highlighter.spec.js +283 -0
  15. package/lib/browser/chat-find/chat-find-highlighter.spec.js.map +1 -0
  16. package/lib/browser/chat-find/chat-find-matcher.d.ts +72 -0
  17. package/lib/browser/chat-find/chat-find-matcher.d.ts.map +1 -0
  18. package/lib/browser/chat-find/chat-find-matcher.js +168 -0
  19. package/lib/browser/chat-find/chat-find-matcher.js.map +1 -0
  20. package/lib/browser/chat-find/chat-find-matcher.spec.d.ts +2 -0
  21. package/lib/browser/chat-find/chat-find-matcher.spec.d.ts.map +1 -0
  22. package/lib/browser/chat-find/chat-find-matcher.spec.js +145 -0
  23. package/lib/browser/chat-find/chat-find-matcher.spec.js.map +1 -0
  24. package/lib/browser/chat-find/chat-find-text.d.ts +20 -0
  25. package/lib/browser/chat-find/chat-find-text.d.ts.map +1 -0
  26. package/lib/browser/chat-find/chat-find-text.js +56 -0
  27. package/lib/browser/chat-find/chat-find-text.js.map +1 -0
  28. package/lib/browser/chat-find/chat-find-widget.d.ts +96 -0
  29. package/lib/browser/chat-find/chat-find-widget.d.ts.map +1 -0
  30. package/lib/browser/chat-find/chat-find-widget.js +332 -0
  31. package/lib/browser/chat-find/chat-find-widget.js.map +1 -0
  32. package/lib/browser/chat-find/chat-find-widget.spec.d.ts +2 -0
  33. package/lib/browser/chat-find/chat-find-widget.spec.d.ts.map +1 -0
  34. package/lib/browser/chat-find/chat-find-widget.spec.js +236 -0
  35. package/lib/browser/chat-find/chat-find-widget.spec.js.map +1 -0
  36. package/lib/browser/chat-input-scroll-util.d.ts +7 -0
  37. package/lib/browser/chat-input-scroll-util.d.ts.map +1 -0
  38. package/lib/browser/chat-input-scroll-util.js +33 -0
  39. package/lib/browser/chat-input-scroll-util.js.map +1 -0
  40. package/lib/browser/chat-input-scroll-util.spec.d.ts +2 -0
  41. package/lib/browser/chat-input-scroll-util.spec.d.ts.map +1 -0
  42. package/lib/browser/chat-input-scroll-util.spec.js +38 -0
  43. package/lib/browser/chat-input-scroll-util.spec.js.map +1 -0
  44. package/lib/browser/chat-input-widget.d.ts +21 -3
  45. package/lib/browser/chat-input-widget.d.ts.map +1 -1
  46. package/lib/browser/chat-input-widget.js +109 -50
  47. package/lib/browser/chat-input-widget.js.map +1 -1
  48. package/lib/browser/chat-input-widget.spec.js +21 -0
  49. package/lib/browser/chat-input-widget.spec.js.map +1 -1
  50. package/lib/browser/chat-response-renderer/code-part-renderer.d.ts.map +1 -1
  51. package/lib/browser/chat-response-renderer/code-part-renderer.js +9 -1
  52. package/lib/browser/chat-response-renderer/code-part-renderer.js.map +1 -1
  53. package/lib/browser/chat-response-renderer/code-wrapper-editors.d.ts +13 -0
  54. package/lib/browser/chat-response-renderer/code-wrapper-editors.d.ts.map +1 -0
  55. package/lib/browser/chat-response-renderer/code-wrapper-editors.js +41 -0
  56. package/lib/browser/chat-response-renderer/code-wrapper-editors.js.map +1 -0
  57. package/lib/browser/chat-response-renderer/delegation-tool-renderer.js +74 -43
  58. package/lib/browser/chat-response-renderer/delegation-tool-renderer.js.map +1 -1
  59. package/lib/browser/chat-response-renderer/index.d.ts +1 -0
  60. package/lib/browser/chat-response-renderer/index.d.ts.map +1 -1
  61. package/lib/browser/chat-response-renderer/index.js +1 -0
  62. package/lib/browser/chat-response-renderer/index.js.map +1 -1
  63. package/lib/browser/chat-response-renderer/markdown-part-renderer.d.ts +9 -0
  64. package/lib/browser/chat-response-renderer/markdown-part-renderer.d.ts.map +1 -1
  65. package/lib/browser/chat-response-renderer/markdown-part-renderer.js +34 -16
  66. package/lib/browser/chat-response-renderer/markdown-part-renderer.js.map +1 -1
  67. package/lib/browser/chat-response-renderer/server-toolcall-part-renderer.d.ts.map +1 -1
  68. package/lib/browser/chat-response-renderer/server-toolcall-part-renderer.js +2 -2
  69. package/lib/browser/chat-response-renderer/server-toolcall-part-renderer.js.map +1 -1
  70. package/lib/browser/chat-response-renderer/spinner.d.ts +3 -0
  71. package/lib/browser/chat-response-renderer/spinner.d.ts.map +1 -0
  72. package/lib/browser/chat-response-renderer/spinner.js +24 -0
  73. package/lib/browser/chat-response-renderer/spinner.js.map +1 -0
  74. package/lib/browser/chat-response-renderer/thinking-part-renderer.d.ts +9 -1
  75. package/lib/browser/chat-response-renderer/thinking-part-renderer.d.ts.map +1 -1
  76. package/lib/browser/chat-response-renderer/thinking-part-renderer.js +93 -2
  77. package/lib/browser/chat-response-renderer/thinking-part-renderer.js.map +1 -1
  78. package/lib/browser/chat-response-renderer/thinking-part-renderer.spec.d.ts +2 -0
  79. package/lib/browser/chat-response-renderer/thinking-part-renderer.spec.d.ts.map +1 -0
  80. package/lib/browser/chat-response-renderer/thinking-part-renderer.spec.js +84 -0
  81. package/lib/browser/chat-response-renderer/thinking-part-renderer.spec.js.map +1 -0
  82. package/lib/browser/chat-response-renderer/toolcall-part-renderer.d.ts.map +1 -1
  83. package/lib/browser/chat-response-renderer/toolcall-part-renderer.js +2 -2
  84. package/lib/browser/chat-response-renderer/toolcall-part-renderer.js.map +1 -1
  85. package/lib/browser/chat-response-renderer/toolcall-part-renderer.spec.js +16 -0
  86. package/lib/browser/chat-response-renderer/toolcall-part-renderer.spec.js.map +1 -1
  87. package/lib/browser/chat-response-renderer/toolcall-utils.d.ts.map +1 -1
  88. package/lib/browser/chat-response-renderer/toolcall-utils.js +7 -2
  89. package/lib/browser/chat-response-renderer/toolcall-utils.js.map +1 -1
  90. package/lib/browser/chat-tree-view/chat-view-tree-container.d.ts.map +1 -1
  91. package/lib/browser/chat-tree-view/chat-view-tree-container.js +4 -0
  92. package/lib/browser/chat-tree-view/chat-view-tree-container.js.map +1 -1
  93. package/lib/browser/chat-tree-view/chat-view-tree-widget.d.ts +20 -1
  94. package/lib/browser/chat-tree-view/chat-view-tree-widget.d.ts.map +1 -1
  95. package/lib/browser/chat-tree-view/chat-view-tree-widget.js +88 -7
  96. package/lib/browser/chat-tree-view/chat-view-tree-widget.js.map +1 -1
  97. package/lib/common/toolcall-utils.d.ts +11 -1
  98. package/lib/common/toolcall-utils.d.ts.map +1 -1
  99. package/lib/common/toolcall-utils.js +191 -9
  100. package/lib/common/toolcall-utils.js.map +1 -1
  101. package/lib/common/toolcall-utils.spec.js +92 -4
  102. package/lib/common/toolcall-utils.spec.js.map +1 -1
  103. package/package.json +13 -13
  104. package/src/browser/ai-chat-ui-frontend-module.ts +7 -0
  105. package/src/browser/chat-find/chat-find-contribution.ts +74 -0
  106. package/src/browser/chat-find/chat-find-highlighter.spec.ts +313 -0
  107. package/src/browser/chat-find/chat-find-highlighter.ts +385 -0
  108. package/src/browser/chat-find/chat-find-matcher.spec.ts +173 -0
  109. package/src/browser/chat-find/chat-find-matcher.ts +197 -0
  110. package/src/browser/chat-find/chat-find-text.ts +65 -0
  111. package/src/browser/chat-find/chat-find-widget.spec.tsx +261 -0
  112. package/src/browser/chat-find/chat-find-widget.tsx +418 -0
  113. package/src/browser/chat-input-scroll-util.spec.ts +42 -0
  114. package/src/browser/chat-input-scroll-util.ts +30 -0
  115. package/src/browser/chat-input-widget.spec.ts +29 -0
  116. package/src/browser/chat-input-widget.tsx +118 -56
  117. package/src/browser/chat-response-renderer/code-part-renderer.tsx +9 -1
  118. package/src/browser/chat-response-renderer/code-wrapper-editors.ts +41 -0
  119. package/src/browser/chat-response-renderer/delegation-tool-renderer.tsx +88 -62
  120. package/src/browser/chat-response-renderer/index.ts +1 -0
  121. package/src/browser/chat-response-renderer/markdown-part-renderer.tsx +34 -17
  122. package/src/browser/chat-response-renderer/server-toolcall-part-renderer.tsx +2 -5
  123. package/src/browser/chat-response-renderer/spinner.tsx +22 -0
  124. package/src/browser/chat-response-renderer/thinking-part-renderer.spec.tsx +104 -0
  125. package/src/browser/chat-response-renderer/thinking-part-renderer.tsx +129 -9
  126. package/src/browser/chat-response-renderer/toolcall-part-renderer.spec.ts +16 -0
  127. package/src/browser/chat-response-renderer/toolcall-part-renderer.tsx +1 -4
  128. package/src/browser/chat-response-renderer/toolcall-utils.ts +5 -0
  129. package/src/browser/chat-tree-view/chat-view-tree-container.ts +4 -0
  130. package/src/browser/chat-tree-view/chat-view-tree-widget.tsx +99 -6
  131. package/src/browser/style/chat-find.css +107 -0
  132. package/src/browser/style/index.css +75 -8
  133. package/src/common/toolcall-utils.spec.ts +113 -4
  134. package/src/common/toolcall-utils.ts +207 -9
@@ -0,0 +1,107 @@
1
+ /********************************************************************************
2
+ * Copyright (C) 2026 Safi Seid-Ahmad, K2view and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the Eclipse Public License v. 2.0 which is available at
6
+ * http://www.eclipse.org/legal/epl-2.0.
7
+ *
8
+ * This Source Code may also be made available under the following Secondary
9
+ * Licenses when the conditions for such availability set forth in the Eclipse
10
+ * Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ * with the GNU Classpath Exception which is available at
12
+ * https://www.gnu.org/software/classpath/license.html.
13
+ *
14
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ ********************************************************************************/
16
+
17
+ /* The widget sits as a zero-height flex item right before the chat tree; the bar overlays the tree's top-right corner. */
18
+ .theia-chat-find-widget {
19
+ position: relative;
20
+ flex: 0 0 0;
21
+ height: 0;
22
+ overflow: visible;
23
+ z-index: 10;
24
+ }
25
+
26
+ /* Input, toggles and buttons use the core `theia-input` and `action-label` styles; this only lays them out. */
27
+ .theia-chat-find-bar {
28
+ position: absolute;
29
+ top: 4px;
30
+ right: 20px;
31
+ max-width: calc(100% - 28px);
32
+ box-sizing: border-box;
33
+ display: flex;
34
+ /* In a narrow view, the count and buttons wrap below the input rather than squeezing it. */
35
+ flex-wrap: wrap;
36
+ justify-content: flex-end;
37
+ align-items: center;
38
+ gap: 2px;
39
+ padding: 2px 4px;
40
+ background: var(--theia-editorWidget-background);
41
+ color: var(--theia-editorWidget-foreground);
42
+ border: var(--theia-border-width) solid var(--theia-widget-border);
43
+ border-radius: 4px;
44
+ box-shadow: 0 0 8px 2px var(--theia-widget-shadow);
45
+ }
46
+
47
+ .theia-chat-find-input-box {
48
+ display: flex;
49
+ flex: 1 1 auto;
50
+ min-width: 0;
51
+ align-items: center;
52
+ background: var(--theia-input-background);
53
+ border: var(--theia-border-width) solid var(--theia-input-border, var(--theia-input-background));
54
+ border-radius: 2px;
55
+ }
56
+
57
+ .theia-chat-find-input-box:focus-within {
58
+ border-color: var(--theia-focusBorder);
59
+ }
60
+
61
+ .theia-chat-find-input-box.no-results {
62
+ border-color: var(--theia-inputValidation-errorBorder);
63
+ }
64
+
65
+ .theia-chat-find-input-box .theia-input {
66
+ width: 140px;
67
+ min-width: 0;
68
+ flex: 1 1 auto;
69
+ min-height: unset;
70
+ padding: 1px 4px;
71
+ border: none;
72
+ outline: none;
73
+ }
74
+
75
+ .theia-chat-find-option {
76
+ flex: none;
77
+ border: var(--theia-border-width) solid transparent;
78
+ background: none;
79
+ color: inherit;
80
+ }
81
+
82
+ .theia-chat-find-option.enabled {
83
+ color: var(--theia-inputOption-activeForeground);
84
+ border-color: var(--theia-inputOption-activeBorder);
85
+ background-color: var(--theia-inputOption-activeBackground);
86
+ }
87
+
88
+ .theia-chat-find-count {
89
+ /* Wide enough for 'No results' and most counts, so the bar does not change width while typing. */
90
+ min-width: 64px;
91
+ margin: 0 4px;
92
+ text-align: center;
93
+ white-space: nowrap;
94
+ font-size: var(--theia-ui-font-size0);
95
+ }
96
+
97
+ .theia-chat-find-count.no-results {
98
+ color: var(--theia-errorForeground);
99
+ }
100
+
101
+ ::highlight(theia-chat-find-match) {
102
+ background-color: var(--theia-editor-findMatchHighlightBackground);
103
+ }
104
+
105
+ ::highlight(theia-chat-find-current) {
106
+ background-color: var(--theia-editor-findMatchBackground);
107
+ }
@@ -1,6 +1,16 @@
1
1
  .chat-view-widget {
2
2
  display: flex;
3
3
  flex-direction: column;
4
+ /* Background of the area the chat is docked in, for elements that must be opaque. */
5
+ --chat-ambient-background: var(--theia-editor-background);
6
+ }
7
+
8
+ .theia-side-panel .chat-view-widget {
9
+ --chat-ambient-background: var(--theia-sideBar-background);
10
+ }
11
+
12
+ #theia-bottom-content-panel .chat-view-widget {
13
+ --chat-ambient-background: var(--theia-panel-background);
4
14
  }
5
15
 
6
16
  .chat-tree-view-widget {
@@ -16,7 +26,7 @@
16
26
  cursor: default;
17
27
  display: flex;
18
28
  flex-direction: column;
19
- padding: 16px 20px 6px 20px;
29
+ padding: 10px 20px 6px 20px;
20
30
  user-select: text;
21
31
  -webkit-user-select: text;
22
32
  border-bottom: 1px solid var(--theia-sideBarSectionHeader-border);
@@ -27,13 +37,36 @@ div:last-child>.theia-ChatNode {
27
37
  border: none;
28
38
  }
29
39
 
40
+ /* Pinned while its node is on screen; opaque to cover the content scrolling underneath. */
30
41
  .theia-ChatNodeHeader {
31
42
  align-items: center;
32
43
  display: flex;
33
44
  justify-content: space-between;
34
45
  height: 24px;
35
46
  gap: 8px;
36
- width: 100%;
47
+ position: sticky;
48
+ top: 0;
49
+ z-index: 2;
50
+ /* Full-bleed: cancel the node's horizontal padding, then re-apply it. */
51
+ margin: 0 -20px;
52
+ padding: var(--theia-ui-padding) 20px;
53
+ width: auto;
54
+ background-color: var(--chat-ambient-background);
55
+ container-type: scroll-state;
56
+ }
57
+
58
+ /* Shadow only while pinned; without scroll-state query support the header stays flat. */
59
+ @container scroll-state(stuck: top) {
60
+ .theia-ChatNodeHeader::after {
61
+ content: '';
62
+ position: absolute;
63
+ top: 100%;
64
+ left: 0;
65
+ right: 0;
66
+ height: calc(var(--theia-ui-padding) * 2 / 3);
67
+ pointer-events: none;
68
+ background: linear-gradient(var(--theia-widget-shadow), transparent);
69
+ }
37
70
  }
38
71
 
39
72
  .theia-ChatNodeHeader .theia-AgentAvatar {
@@ -66,12 +99,6 @@ div:last-child>.theia-ChatNode {
66
99
  color: var(--theia-progressBar-background);
67
100
  }
68
101
 
69
- .theia-ChatNodeHeader .theia-ChatContentInProgress-Cancel {
70
- position: absolute;
71
- z-index: 999;
72
- right: 20px;
73
- }
74
-
75
102
  .theia-ChatNode .codicon {
76
103
  text-align: left;
77
104
  }
@@ -1298,6 +1325,46 @@ details.theia-tool-confirmation-args>summary::marker {
1298
1325
  text-wrap: auto;
1299
1326
  }
1300
1327
 
1328
+ .theia-thinking.active {
1329
+ cursor: default;
1330
+ }
1331
+
1332
+ .theia-thinking-status {
1333
+ display: flex;
1334
+ align-items: center;
1335
+ gap: calc(var(--theia-ui-padding) / 2);
1336
+ }
1337
+
1338
+ /* Registered so that the fade can be transitioned. */
1339
+ @property --theia-thinking-preview-fade {
1340
+ syntax: '<length>';
1341
+ inherits: false;
1342
+ initial-value: 0px;
1343
+ }
1344
+
1345
+ /* Last lines of the reasoning, anchored to the bottom and clipped at the top. */
1346
+ .theia-thinking-preview {
1347
+ position: relative;
1348
+ display: flex;
1349
+ flex-direction: column;
1350
+ justify-content: flex-end;
1351
+ max-height: 3lh;
1352
+ margin-left: calc(var(--theia-icon-size) + var(--theia-ui-padding) / 2);
1353
+ overflow: hidden;
1354
+ user-select: none;
1355
+ mask-image: linear-gradient(to bottom, transparent, black var(--theia-thinking-preview-fade));
1356
+ transition: --theia-thinking-preview-fade 600ms ease-out;
1357
+ }
1358
+
1359
+ .theia-thinking-preview-text {
1360
+ flex-shrink: 0;
1361
+ overflow-wrap: anywhere;
1362
+ }
1363
+
1364
+ .theia-thinking-preview.overflowing {
1365
+ --theia-thinking-preview-fade: 1lh;
1366
+ }
1367
+
1301
1368
  .theia-toolCall-args-label {
1302
1369
  font-weight: normal;
1303
1370
  color: var(--theia-descriptionForeground);
@@ -103,16 +103,125 @@ describe('extractJsonStringField', () => {
103
103
  expect(result).to.be.undefined;
104
104
  });
105
105
 
106
- it('should handle escaped quotes - regex stops at first unescaped quote', () => {
106
+ it('should handle escaped quotes in complete JSON', () => {
107
107
  // With complete JSON, JSON.parse handles escapes correctly
108
108
  const result = extractJsonStringField('{"cmd": "echo \\"hello\\""}', 'cmd');
109
109
  expect(result).to.equal('echo "hello"');
110
110
  });
111
111
 
112
- it('should handle escaped quotes in partial JSON - regex captures up to first quote', () => {
113
- // With incomplete JSON, regex stops at the first unescaped quote character
112
+ it('should unescape escaped quotes in partial JSON', () => {
114
113
  const result = extractJsonStringField('{"cmd": "echo \\"hello', 'cmd');
115
- expect(result).to.equal('echo \\');
114
+ expect(result).to.equal('echo "hello');
116
115
  });
117
116
  });
117
+
118
+ describe('parse/scan boundary', () => {
119
+ it('should unescape via JSON.parse when complete JSON has trailing whitespace', () => {
120
+ const result = extractJsonStringField('{"cmd": "echo \\"hello\\""}\n', 'cmd');
121
+ expect(result).to.equal('echo "hello"');
122
+ });
123
+
124
+ it('should extract by scanning when JSON ends with a brace but is still incomplete', () => {
125
+ const result = extractJsonStringField('{"path": "src/index.ts", "meta": {"x": "y"}', 'path');
126
+ expect(result).to.equal('src/index.ts');
127
+ });
128
+
129
+ it('should return undefined for a complete array without the field', () => {
130
+ const result = extractJsonStringField('[{"path": "src/index.ts"}]', 'other');
131
+ expect(result).to.be.undefined;
132
+ });
133
+
134
+ it('should return undefined for an incomplete array, which has no top-level field', () => {
135
+ const result = extractJsonStringField('[{"path": "src/index.ts"', 'path');
136
+ expect(result).to.be.undefined;
137
+ });
138
+ });
139
+
140
+ describe('top-level fields only (streaming)', () => {
141
+ it('should ignore a matching key inside a nested object', () => {
142
+ const result = extractJsonStringField('{"meta": {"path": "inner.ts"}, "path": "outer.ts', 'path');
143
+ expect(result).to.equal('outer.ts');
144
+ });
145
+
146
+ it('should ignore a matching key inside a nested array of objects', () => {
147
+ const result = extractJsonStringField('{"edits": [{"path": "inner.ts"}], "path": "outer.ts', 'path');
148
+ expect(result).to.equal('outer.ts');
149
+ });
150
+
151
+ it('should ignore a matching key inside a string value', () => {
152
+ const result = extractJsonStringField('{"content": "{\\"path\\": \\"inner.ts\\"}", "path": "outer.ts', 'path');
153
+ expect(result).to.equal('outer.ts');
154
+ });
155
+
156
+ it('should return undefined when the field only occurs nested', () => {
157
+ const result = extractJsonStringField('{"meta": {"path": "inner.ts"}, "content": "abc', 'path');
158
+ expect(result).to.be.undefined;
159
+ });
160
+
161
+ it('should return undefined while a nested object is still streaming', () => {
162
+ const result = extractJsonStringField('{"meta": {"path": "inner.ts', 'path');
163
+ expect(result).to.be.undefined;
164
+ });
165
+
166
+ it('should return undefined when the top-level field is not a string', () => {
167
+ const result = extractJsonStringField('{"path": 42, "content": "abc', 'path');
168
+ expect(result).to.be.undefined;
169
+ });
170
+
171
+ it('should extract a field that follows a non-string top-level value', () => {
172
+ const result = extractJsonStringField('{"line": 42, "path": "src/index.ts', 'path');
173
+ expect(result).to.equal('src/index.ts');
174
+ });
175
+
176
+ it('should extract a field that follows a nested object', () => {
177
+ const result = extractJsonStringField('{"meta": {"a": {"b": "c"}}, "path": "src/index.ts', 'path');
178
+ expect(result).to.equal('src/index.ts');
179
+ });
180
+
181
+ it('should not match a key whose name merely ends with the field name', () => {
182
+ const result = extractJsonStringField('{"filepath": "wrong.ts", "path": "right.ts', 'path');
183
+ expect(result).to.equal('right.ts');
184
+ });
185
+ });
186
+
187
+ describe('escapes in streaming values', () => {
188
+ it('should not end the value at an escaped quote', () => {
189
+ const result = extractJsonStringField('{"path": "a\\"b", "other": "x', 'path');
190
+ expect(result).to.equal('a"b');
191
+ });
192
+
193
+ it('should unescape backslashes in a streaming Windows path', () => {
194
+ const result = extractJsonStringField('{"path": "C:\\\\Users\\\\me', 'path');
195
+ expect(result).to.equal('C:\\Users\\me');
196
+ });
197
+
198
+ it('should drop a trailing incomplete escape', () => {
199
+ const result = extractJsonStringField('{"path": "a\\', 'path');
200
+ expect(result).to.equal('a');
201
+ });
202
+
203
+ it('should drop a trailing incomplete unicode escape', () => {
204
+ const result = extractJsonStringField('{"path": "a\\u00', 'path');
205
+ expect(result).to.equal('a');
206
+ });
207
+
208
+ it('should keep a completed escaped backslash at the end of a streaming value', () => {
209
+ const result = extractJsonStringField('{"path": "a\\\\', 'path');
210
+ expect(result).to.equal('a\\');
211
+ });
212
+
213
+ it('should keep literal text that looks like an escape', () => {
214
+ const result = extractJsonStringField('{"path": "a\\\\u0041', 'path');
215
+ expect(result).to.equal('a\\u0041');
216
+ });
217
+ });
218
+
219
+ describe('large streaming payloads', () => {
220
+ it('should extract a field that follows a very large content value', () => {
221
+ const content = 'x'.repeat(1_000_000);
222
+ const result = extractJsonStringField(`{"content": "${content}", "path": "src/index.ts`, 'path');
223
+ expect(result).to.equal('src/index.ts');
224
+ });
225
+ });
226
+
118
227
  });
@@ -14,23 +14,221 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
+ /**
18
+ * Tells whether `json` may already be complete, i.e. whether handing it to `JSON.parse` can succeed at all.
19
+ *
20
+ * Tool call arguments are rendered repeatedly while they stream in, and the arguments object is truncated
21
+ * until the last delta arrives: `JSON.parse` would scan the whole string only to throw, which gets quadratic
22
+ * as the arguments grow (noticeable with large payloads such as whole-file writes). An arguments object can
23
+ * only be complete once it ends with `}`, so callers skip the parse until then. Text that does not start
24
+ * with `{` is not a streaming arguments object and is always considered worth parsing.
25
+ */
26
+ export function mayBeCompleteJson(json: string): boolean {
27
+ const trimmed = json.trim();
28
+ return !trimmed.startsWith('{') || trimmed.endsWith('}');
29
+ }
30
+
17
31
  /**
18
32
  * Extracts a string field value from potentially incomplete JSON.
19
- * Tries JSON.parse first, then falls back to regex extraction for streaming scenarios.
33
+ * Uses JSON.parse for complete JSON, and a scan of the top-level entries for streaming scenarios.
20
34
  */
21
35
  export function extractJsonStringField(json: string | undefined, fieldName: string): string | undefined {
22
36
  if (!json) {
23
37
  return undefined;
24
38
  }
25
- try {
26
- const parsed = JSON.parse(json);
27
- if (parsed && typeof parsed === 'object' && fieldName in parsed && typeof parsed[fieldName] === 'string') {
28
- return parsed[fieldName];
39
+ if (mayBeCompleteJson(json)) {
40
+ try {
41
+ const parsed = JSON.parse(json);
42
+ if (parsed && typeof parsed === 'object' && fieldName in parsed && typeof parsed[fieldName] === 'string') {
43
+ return parsed[fieldName];
44
+ }
45
+ return undefined;
46
+ } catch {
47
+ // fall through to the streaming extraction
48
+ }
49
+ }
50
+ return extractTopLevelStringField(json, fieldName);
51
+ }
52
+
53
+ const BACKSLASH = 92;
54
+
55
+ /** A JSON string token, which may be cut off by the end of a streaming payload. */
56
+ interface ScannedString {
57
+ /** The index right after the last character of the body, i.e. the end of the still escaped contents. */
58
+ bodyEnd: number;
59
+ /** The index right after the closing quote, or the end of the input if there is none yet. */
60
+ end: number;
61
+ /** Whether the closing quote was found. */
62
+ terminated: boolean;
63
+ }
64
+
65
+ /**
66
+ * Extracts a top-level string field from a truncated JSON object by walking its entries.
67
+ *
68
+ * A regex over the whole payload cannot tell nesting apart, so it reports the first `"<field>": "..."` anywhere,
69
+ * including one inside a nested object or array - a `{"edits": [{"path": "a"}], "path": "b"` argument object would
70
+ * be labelled with `a`. Walking the entries skips over nested values and over string contents, so only the keys of
71
+ * the arguments object itself can match, and escapes within the value are resolved instead of ending it early.
72
+ *
73
+ * Values are skipped with `indexOf`, which keeps large payloads (e.g. whole-file writes) a native scan.
74
+ */
75
+ function extractTopLevelStringField(json: string, fieldName: string): string | undefined {
76
+ let index = skipWhitespace(json, 0);
77
+ if (json[index] !== '{') {
78
+ // tool call arguments are always an object; anything else has no top-level field to report
79
+ return undefined;
80
+ }
81
+ index = skipWhitespace(json, index + 1);
82
+ while (index < json.length && json[index] !== '}') {
83
+ if (json[index] !== '"') {
84
+ return undefined;
85
+ }
86
+ const keyStart = index;
87
+ const key = readString(json, index);
88
+ if (!key.terminated) {
89
+ return undefined;
90
+ }
91
+ index = skipWhitespace(json, key.end);
92
+ if (json[index] !== ':') {
93
+ return undefined;
94
+ }
95
+ index = skipWhitespace(json, index + 1);
96
+ if (index >= json.length) {
97
+ return undefined;
98
+ }
99
+ const matches = matchesKey(json, keyStart, key, fieldName);
100
+ if (json[index] === '"') {
101
+ const value = readString(json, index);
102
+ if (matches) {
103
+ return unescapeJsonStringContent(json.substring(index + 1, value.bodyEnd), value.terminated);
104
+ }
105
+ if (!value.terminated) {
106
+ return undefined;
107
+ }
108
+ index = value.end;
109
+ } else {
110
+ if (matches) {
111
+ // the field is present, but not a string - same as what the JSON.parse branch reports
112
+ return undefined;
113
+ }
114
+ const end = skipValue(json, index);
115
+ if (end < 0) {
116
+ return undefined;
117
+ }
118
+ index = end;
119
+ }
120
+ index = skipWhitespace(json, index);
121
+ if (json[index] === ',') {
122
+ index = skipWhitespace(json, index + 1);
29
123
  }
30
- } catch {
31
- const regex = new RegExp('"' + fieldName + '"\\s*:\\s*"([^"]*)"?');
32
- const match = regex.exec(json);
33
- return match?.[1];
34
124
  }
35
125
  return undefined;
36
126
  }
127
+
128
+ /**
129
+ * Reads the string token starting at `start`, which must be a quote.
130
+ *
131
+ * The closing quote is searched with `indexOf`, so the body - up to megabytes of file content - is skipped by a native
132
+ * scan rather than a per-character loop, and only escaped quotes cost an iteration. Only the bounds are reported: the
133
+ * body of a value that is merely skipped over is never materialized.
134
+ */
135
+ function readString(json: string, start: number): ScannedString {
136
+ let index = start + 1;
137
+ for (;;) {
138
+ const quote = json.indexOf('"', index);
139
+ if (quote < 0) {
140
+ return { bodyEnd: json.length, end: json.length, terminated: false };
141
+ }
142
+ if (!isEscaped(json, quote)) {
143
+ return { bodyEnd: quote, end: quote + 1, terminated: true };
144
+ }
145
+ index = quote + 1;
146
+ }
147
+ }
148
+
149
+ /** Skips the non-string value starting at `start`, returning the index right after it, or `-1` if it is still streaming. */
150
+ function skipValue(json: string, start: number): number {
151
+ let index = start;
152
+ if (json[index] === '{' || json[index] === '[') {
153
+ let depth = 0;
154
+ while (index < json.length) {
155
+ const character = json[index];
156
+ if (character === '"') {
157
+ const contained = readString(json, index);
158
+ if (!contained.terminated) {
159
+ return -1;
160
+ }
161
+ index = contained.end;
162
+ continue;
163
+ }
164
+ if (character === '{' || character === '[') {
165
+ depth++;
166
+ } else if (character === '}' || character === ']') {
167
+ depth--;
168
+ if (depth === 0) {
169
+ return index + 1;
170
+ }
171
+ }
172
+ index++;
173
+ }
174
+ return -1;
175
+ }
176
+ while (index < json.length && json[index] !== ',' && json[index] !== '}') {
177
+ index++;
178
+ }
179
+ return index < json.length ? index : -1;
180
+ }
181
+
182
+ function skipWhitespace(json: string, start: number): number {
183
+ let index = start;
184
+ while (index < json.length) {
185
+ const character = json[index];
186
+ if (character !== ' ' && character !== '\n' && character !== '\r' && character !== '\t') {
187
+ break;
188
+ }
189
+ index++;
190
+ }
191
+ return index;
192
+ }
193
+
194
+ /** Tells whether the character at `index` is preceded by an odd number of backslashes, i.e. whether it is escaped. */
195
+ function isEscaped(text: string, index: number): boolean {
196
+ let backslashes = 0;
197
+ while (index - backslashes > 0 && text.charCodeAt(index - backslashes - 1) === BACKSLASH) {
198
+ backslashes++;
199
+ }
200
+ return backslashes % 2 === 1;
201
+ }
202
+
203
+ /** Compares a scanned key against the wanted field name, resolving escapes only in the rare case that the key has any. */
204
+ function matchesKey(json: string, keyStart: number, key: ScannedString, fieldName: string): boolean {
205
+ const raw = json.substring(keyStart + 1, key.bodyEnd);
206
+ return (raw.indexOf('\\') < 0 ? raw : unescapeJsonStringContent(raw, true)) === fieldName;
207
+ }
208
+
209
+ /** Resolves the escapes of a JSON string body, dropping the escape the stream was cut off in the middle of. */
210
+ function unescapeJsonStringContent(raw: string, terminated: boolean): string {
211
+ const content = terminated ? raw : dropIncompleteEscape(raw);
212
+ try {
213
+ return JSON.parse('"' + content + '"');
214
+ } catch {
215
+ // control characters and other invalid contents: the raw text is still the best label we have
216
+ return content;
217
+ }
218
+ }
219
+
220
+ /** Drops a trailing escape sequence that has not fully arrived yet, so that the value can be unescaped. */
221
+ function dropIncompleteEscape(raw: string): string {
222
+ let backslashes = 0;
223
+ while (backslashes < raw.length && raw.charCodeAt(raw.length - backslashes - 1) === BACKSLASH) {
224
+ backslashes++;
225
+ }
226
+ if (backslashes % 2 === 1) {
227
+ return raw.substring(0, raw.length - 1);
228
+ }
229
+ const unicodeEscape = /\\u[0-9a-fA-F]{0,3}$/.exec(raw);
230
+ if (unicodeEscape && !isEscaped(raw, unicodeEscape.index)) {
231
+ return raw.substring(0, unicodeEscape.index);
232
+ }
233
+ return raw;
234
+ }