@thacio/auditaria 0.28.0 → 0.30.1

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 (38) hide show
  1. package/bundle/docs/CONTRIBUTING.md +7 -6
  2. package/bundle/docs/changelogs/index.md +20 -0
  3. package/bundle/docs/changelogs/latest.md +294 -426
  4. package/bundle/docs/changelogs/preview.md +343 -283
  5. package/bundle/docs/cli/cli-reference.md +23 -23
  6. package/bundle/docs/cli/commands.md +2 -0
  7. package/bundle/docs/cli/enterprise.md +18 -15
  8. package/bundle/docs/cli/keyboard-shortcuts.md +17 -8
  9. package/bundle/docs/cli/plan-mode.md +92 -12
  10. package/bundle/docs/cli/sandbox.md +3 -2
  11. package/bundle/docs/cli/settings.md +28 -19
  12. package/bundle/docs/cli/telemetry.md +18 -4
  13. package/bundle/docs/core/policy-engine.md +13 -3
  14. package/bundle/docs/extensions/reference.md +0 -3
  15. package/bundle/docs/get-started/configuration-v1.md +5 -3
  16. package/bundle/docs/get-started/configuration.md +85 -41
  17. package/bundle/docs/tools/ask-user.md +95 -0
  18. package/bundle/docs/tools/index.md +3 -0
  19. package/bundle/docs/tools/mcp-server.md +1 -12
  20. package/bundle/docs/tools/planning.md +55 -0
  21. package/bundle/docs/tools/shell.md +7 -6
  22. package/bundle/gemini.js +30500 -18105
  23. package/bundle/mcp-bridge.js +2 -2
  24. package/bundle/policies/plan.toml +3 -3
  25. package/bundle/policies/yolo.toml +13 -2
  26. package/bundle/{sandbox-macos-restrictive-closed.sb → sandbox-macos-strict-open.sb} +42 -4
  27. package/bundle/sandbox-macos-strict-proxied.sb +133 -0
  28. package/bundle/web-client/client.js +96 -3
  29. package/bundle/web-client/components/DiffContextMenu.js +252 -0
  30. package/bundle/web-client/components/DiffModal.js +85 -38
  31. package/bundle/web-client/components/EditorPanel.js +12 -2
  32. package/bundle/web-client/managers/EditorManager.js +32 -0
  33. package/bundle/web-client/managers/InputHistoryManager.js +139 -0
  34. package/bundle/web-client/managers/WebSocketManager.js +19 -4
  35. package/bundle/web-client/styles/editor-panel.css +32 -24
  36. package/bundle/web-client/styles/overhaul.css +30 -0
  37. package/package.json +4 -4
  38. package/bundle/sandbox-macos-permissive-closed.sb +0 -32
@@ -17723,10 +17723,10 @@ function isTerminal(status) {
17723
17723
  return status === "completed" || status === "failed" || status === "cancelled";
17724
17724
  }
17725
17725
 
17726
- // packages/core/node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema/dist/esm/Options.js
17726
+ // node_modules/zod-to-json-schema/dist/esm/Options.js
17727
17727
  var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use");
17728
17728
 
17729
- // packages/core/node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema/dist/esm/parsers/string.js
17729
+ // node_modules/zod-to-json-schema/dist/esm/parsers/string.js
17730
17730
  var ALPHA_NUMERIC = new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789");
17731
17731
 
17732
17732
  // packages/core/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-json-schema-compat.js
@@ -31,12 +31,12 @@
31
31
  decision = "deny"
32
32
  priority = 60
33
33
  modes = ["plan"]
34
- deny_message = "You are in Plan Mode - adjust your prompt to only use read and search tools."
34
+ deny_message = "You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked."
35
35
 
36
36
  # Explicitly Allow Read-Only Tools in Plan mode.
37
37
 
38
38
  [[rule]]
39
- toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search"]
39
+ toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"]
40
40
  decision = "allow"
41
41
  priority = 70
42
42
  modes = ["plan"]
@@ -53,4 +53,4 @@ toolName = ["write_file", "replace"]
53
53
  decision = "allow"
54
54
  priority = 70
55
55
  modes = ["plan"]
56
- argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-zA-Z0-9_-]+/plans/[a-zA-Z0-9_-]+\\.md\""
56
+ argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+/plans/[a-zA-Z0-9_-]+\\.md\""
@@ -23,10 +23,21 @@
23
23
  # 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
24
24
  # 15: Auto-edit tool override (becomes 1.015 in default tier)
25
25
  # 50: Read-only tools (becomes 1.050 in default tier)
26
- # 999: YOLO mode allow-all (becomes 1.999 in default tier)
26
+ # 998: YOLO mode allow-all (becomes 1.998 in default tier)
27
+ # 999: Ask-user tool (becomes 1.999 in default tier)
27
28
 
29
+ # Ask-user tool always requires user interaction, even in YOLO mode.
30
+ # This ensures the model can gather user preferences/decisions when needed.
31
+ # Note: In non-interactive mode, this decision is converted to DENY by the policy engine.
28
32
  [[rule]]
29
- decision = "allow"
33
+ toolName = "ask_user"
34
+ decision = "ask_user"
30
35
  priority = 999
31
36
  modes = ["yolo"]
37
+
38
+ # Allow everything else in YOLO mode
39
+ [[rule]]
40
+ decision = "allow"
41
+ priority = 998
42
+ modes = ["yolo"]
32
43
  allow_redirection = true
@@ -3,8 +3,43 @@
3
3
  ;; deny everything by default
4
4
  (deny default)
5
5
 
6
- ;; allow reading files from anywhere on host
7
- (allow file-read*)
6
+ ;; allow reading ONLY from working directory, system paths, and essential user paths
7
+ (allow file-read*
8
+ (literal "/")
9
+ (subpath (param "TARGET_DIR"))
10
+ (subpath (param "TMP_DIR"))
11
+ (subpath (param "CACHE_DIR"))
12
+ ;; Only allow reading essential dotfiles/directories under HOME, not the entire HOME
13
+ (subpath (string-append (param "HOME_DIR") "/.gemini"))
14
+ (subpath (string-append (param "HOME_DIR") "/.npm"))
15
+ (subpath (string-append (param "HOME_DIR") "/.cache"))
16
+ (literal (string-append (param "HOME_DIR") "/.gitconfig"))
17
+ (subpath (string-append (param "HOME_DIR") "/.nvm"))
18
+ (subpath (string-append (param "HOME_DIR") "/.fnm"))
19
+ (subpath (string-append (param "HOME_DIR") "/.node"))
20
+ (subpath (string-append (param "HOME_DIR") "/.config"))
21
+ ;; Allow reads from included directories
22
+ (subpath (param "INCLUDE_DIR_0"))
23
+ (subpath (param "INCLUDE_DIR_1"))
24
+ (subpath (param "INCLUDE_DIR_2"))
25
+ (subpath (param "INCLUDE_DIR_3"))
26
+ (subpath (param "INCLUDE_DIR_4"))
27
+ ;; System paths required for Node.js, shell, and common tools
28
+ (subpath "/usr")
29
+ (subpath "/bin")
30
+ (subpath "/sbin")
31
+ (subpath "/Library")
32
+ (subpath "/System")
33
+ (subpath "/private")
34
+ (subpath "/dev")
35
+ (subpath "/etc")
36
+ (subpath "/opt")
37
+ (subpath "/Applications")
38
+ )
39
+
40
+ ;; allow path traversal everywhere (metadata only: stat/lstat, NOT readdir or file content)
41
+ ;; this is needed for Node.js module resolution to traverse intermediate directories
42
+ (allow file-read-metadata)
8
43
 
9
44
  ;; allow exec/fork (children inherit policy)
10
45
  (allow process-exec)
@@ -70,7 +105,7 @@
70
105
  (subpath (string-append (param "HOME_DIR") "/.gemini"))
71
106
  (subpath (string-append (param "HOME_DIR") "/.npm"))
72
107
  (subpath (string-append (param "HOME_DIR") "/.cache"))
73
- (subpath (string-append (param "HOME_DIR") "/.gitconfig"))
108
+ (literal (string-append (param "HOME_DIR") "/.gitconfig"))
74
109
  ;; Allow writes to included directories from --include-directories
75
110
  (subpath (param "INCLUDE_DIR_0"))
76
111
  (subpath (param "INCLUDE_DIR_1"))
@@ -90,4 +125,7 @@
90
125
  (allow file-ioctl (regex #"^/dev/tty.*"))
91
126
 
92
127
  ;; allow inbound network traffic on debugger port
93
- (allow network-inbound (local ip "localhost:9229"))
128
+ (allow network-inbound (local ip "localhost:9229"))
129
+
130
+ ;; allow all outbound network traffic
131
+ (allow network-outbound)
@@ -0,0 +1,133 @@
1
+ (version 1)
2
+
3
+ ;; deny everything by default
4
+ (deny default)
5
+
6
+ ;; allow reading ONLY from working directory, system paths, and essential user paths
7
+ (allow file-read*
8
+ (literal "/")
9
+ (subpath (param "TARGET_DIR"))
10
+ (subpath (param "TMP_DIR"))
11
+ (subpath (param "CACHE_DIR"))
12
+ ;; Only allow reading essential dotfiles/directories under HOME, not the entire HOME
13
+ (subpath (string-append (param "HOME_DIR") "/.gemini"))
14
+ (subpath (string-append (param "HOME_DIR") "/.npm"))
15
+ (subpath (string-append (param "HOME_DIR") "/.cache"))
16
+ (literal (string-append (param "HOME_DIR") "/.gitconfig"))
17
+ (subpath (string-append (param "HOME_DIR") "/.nvm"))
18
+ (subpath (string-append (param "HOME_DIR") "/.fnm"))
19
+ (subpath (string-append (param "HOME_DIR") "/.node"))
20
+ (subpath (string-append (param "HOME_DIR") "/.config"))
21
+ ;; Allow reads from included directories
22
+ (subpath (param "INCLUDE_DIR_0"))
23
+ (subpath (param "INCLUDE_DIR_1"))
24
+ (subpath (param "INCLUDE_DIR_2"))
25
+ (subpath (param "INCLUDE_DIR_3"))
26
+ (subpath (param "INCLUDE_DIR_4"))
27
+ ;; System paths required for Node.js, shell, and common tools
28
+ (subpath "/usr")
29
+ (subpath "/bin")
30
+ (subpath "/sbin")
31
+ (subpath "/Library")
32
+ (subpath "/System")
33
+ (subpath "/private")
34
+ (subpath "/dev")
35
+ (subpath "/etc")
36
+ (subpath "/opt")
37
+ (subpath "/Applications")
38
+ )
39
+
40
+ ;; allow path traversal everywhere (metadata only: stat/lstat, NOT readdir or file content)
41
+ ;; this is needed for Node.js module resolution to traverse intermediate directories
42
+ (allow file-read-metadata)
43
+
44
+ ;; allow exec/fork (children inherit policy)
45
+ (allow process-exec)
46
+ (allow process-fork)
47
+
48
+ ;; allow signals to self, e.g. SIGPIPE on write to closed pipe
49
+ (allow signal (target self))
50
+
51
+ ;; allow read access to specific information about system
52
+ ;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
53
+ (allow sysctl-read
54
+ (sysctl-name "hw.activecpu")
55
+ (sysctl-name "hw.busfrequency_compat")
56
+ (sysctl-name "hw.byteorder")
57
+ (sysctl-name "hw.cacheconfig")
58
+ (sysctl-name "hw.cachelinesize_compat")
59
+ (sysctl-name "hw.cpufamily")
60
+ (sysctl-name "hw.cpufrequency_compat")
61
+ (sysctl-name "hw.cputype")
62
+ (sysctl-name "hw.l1dcachesize_compat")
63
+ (sysctl-name "hw.l1icachesize_compat")
64
+ (sysctl-name "hw.l2cachesize_compat")
65
+ (sysctl-name "hw.l3cachesize_compat")
66
+ (sysctl-name "hw.logicalcpu_max")
67
+ (sysctl-name "hw.machine")
68
+ (sysctl-name "hw.ncpu")
69
+ (sysctl-name "hw.nperflevels")
70
+ (sysctl-name "hw.optional.arm.FEAT_BF16")
71
+ (sysctl-name "hw.optional.arm.FEAT_DotProd")
72
+ (sysctl-name "hw.optional.arm.FEAT_FCMA")
73
+ (sysctl-name "hw.optional.arm.FEAT_FHM")
74
+ (sysctl-name "hw.optional.arm.FEAT_FP16")
75
+ (sysctl-name "hw.optional.arm.FEAT_I8MM")
76
+ (sysctl-name "hw.optional.arm.FEAT_JSCVT")
77
+ (sysctl-name "hw.optional.arm.FEAT_LSE")
78
+ (sysctl-name "hw.optional.arm.FEAT_RDM")
79
+ (sysctl-name "hw.optional.arm.FEAT_SHA512")
80
+ (sysctl-name "hw.optional.armv8_2_sha512")
81
+ (sysctl-name "hw.packages")
82
+ (sysctl-name "hw.pagesize_compat")
83
+ (sysctl-name "hw.physicalcpu_max")
84
+ (sysctl-name "hw.tbfrequency_compat")
85
+ (sysctl-name "hw.vectorunit")
86
+ (sysctl-name "kern.hostname")
87
+ (sysctl-name "kern.maxfilesperproc")
88
+ (sysctl-name "kern.osproductversion")
89
+ (sysctl-name "kern.osrelease")
90
+ (sysctl-name "kern.ostype")
91
+ (sysctl-name "kern.osvariant_status")
92
+ (sysctl-name "kern.osversion")
93
+ (sysctl-name "kern.secure_kernel")
94
+ (sysctl-name "kern.usrstack64")
95
+ (sysctl-name "kern.version")
96
+ (sysctl-name "sysctl.proc_cputype")
97
+ (sysctl-name-prefix "hw.perflevel")
98
+ )
99
+
100
+ ;; allow writes to specific paths
101
+ (allow file-write*
102
+ (subpath (param "TARGET_DIR"))
103
+ (subpath (param "TMP_DIR"))
104
+ (subpath (param "CACHE_DIR"))
105
+ (subpath (string-append (param "HOME_DIR") "/.gemini"))
106
+ (subpath (string-append (param "HOME_DIR") "/.npm"))
107
+ (subpath (string-append (param "HOME_DIR") "/.cache"))
108
+ (literal (string-append (param "HOME_DIR") "/.gitconfig"))
109
+ ;; Allow writes to included directories from --include-directories
110
+ (subpath (param "INCLUDE_DIR_0"))
111
+ (subpath (param "INCLUDE_DIR_1"))
112
+ (subpath (param "INCLUDE_DIR_2"))
113
+ (subpath (param "INCLUDE_DIR_3"))
114
+ (subpath (param "INCLUDE_DIR_4"))
115
+ (literal "/dev/stdout")
116
+ (literal "/dev/stderr")
117
+ (literal "/dev/null")
118
+ )
119
+
120
+ ;; allow communication with sysmond for process listing (e.g. for pgrep)
121
+ (allow mach-lookup (global-name "com.apple.sysmond"))
122
+
123
+ ;; enable terminal access required by ink
124
+ ;; fixes setRawMode EPERM failure (at node:tty:81:24)
125
+ (allow file-ioctl (regex #"^/dev/tty.*"))
126
+
127
+ ;; allow inbound network traffic on debugger port
128
+ (allow network-inbound (local ip "localhost:9229"))
129
+
130
+ ;; allow outbound network traffic through proxy on localhost:8877
131
+ ;; set `GEMINI_SANDBOX_PROXY_COMMAND=<command>` to run proxy alongside sandbox
132
+ ;; proxy must listen on :::8877 (see docs/examples/proxy-script.md)
133
+ (allow network-outbound (remote tcp "localhost:8877"))
@@ -17,6 +17,7 @@ import { attachmentCacheManager } from './managers/AttachmentCacheManager.js';
17
17
  import { ttsManager } from './providers/tts/TTSManager.js';
18
18
  import { ConfirmationQueue } from './confirmation-queue.js';
19
19
  import { SlashAutocompleteManager } from './managers/SlashAutocompleteManager.js';
20
+ import { InputHistoryManager } from './managers/InputHistoryManager.js';
20
21
  import { themeManager } from './utils/theme-manager.js';
21
22
  import { layoutManager } from './utils/layout-manager.js';
22
23
  import { showErrorToast, showInfoToast } from './components/Toast.js';
@@ -60,6 +61,9 @@ class AuditariaWebClient {
60
61
  // Initialize slash command autocomplete (after UI init)
61
62
  this.slashAutocomplete = null; // Will be initialized after UI elements are ready
62
63
 
64
+ // Initialize input history (ArrowUp/Down navigation)
65
+ this.inputHistory = new InputHistoryManager();
66
+
63
67
  // State properties
64
68
  this.hasFooterData = false;
65
69
  this.latestFooterData = null;
@@ -188,6 +192,10 @@ class AuditariaWebClient {
188
192
 
189
193
  this.wsManager.addEventListener('history_item', (e) => {
190
194
  this.messageManager.addHistoryItem(e.detail);
195
+ // Track user messages for input history (captures CLI-side inputs too)
196
+ if (e.detail.type === 'user' && e.detail.text) {
197
+ this.inputHistory.addInput(e.detail.text);
198
+ }
191
199
  });
192
200
 
193
201
  // WEB_INTERFACE: Unified response state replaces fragmented pending_item
@@ -236,6 +244,20 @@ class AuditariaWebClient {
236
244
 
237
245
  this.wsManager.addEventListener('history_sync', (e) => {
238
246
  this.messageManager.loadHistoryItems(e.detail.history);
247
+ // Fallback: populate input history from conversation history
248
+ // (overridden by input_history_sync if the CLI sends it)
249
+ if (this.inputHistory.length === 0) {
250
+ const userMessages = (e.detail.history || [])
251
+ .filter(item => item.type === 'user' && item.text)
252
+ .map(item => item.text.trim())
253
+ .filter(Boolean);
254
+ this.inputHistory.loadHistory(userMessages);
255
+ }
256
+ });
257
+
258
+ // Input history sync from CLI (includes past sessions — shared with CLI's ArrowUp/Down)
259
+ this.wsManager.addEventListener('input_history_sync', (e) => {
260
+ this.inputHistory.loadHistory(e.detail.history || []);
239
261
  });
240
262
 
241
263
  this.wsManager.addEventListener('loading_state', (e) => {
@@ -309,6 +331,32 @@ class AuditariaWebClient {
309
331
  if (event.key === 'Enter' && !event.shiftKey) {
310
332
  event.preventDefault();
311
333
  this.sendMessage();
334
+ return;
335
+ }
336
+
337
+ // ArrowUp/Down — input history navigation
338
+ if (event.key === 'ArrowUp' && this._cursorOnFirstLine()) {
339
+ const text = this.inputHistory.navigateUp(this.messageInput.value);
340
+ if (text !== null) {
341
+ event.preventDefault();
342
+ this.messageInput.value = text;
343
+ this.autoResizeTextarea();
344
+ // Place cursor at end
345
+ this.messageInput.setSelectionRange(text.length, text.length);
346
+ }
347
+ return;
348
+ }
349
+
350
+ if (event.key === 'ArrowDown' && this._cursorOnLastLine()) {
351
+ const text = this.inputHistory.navigateDown();
352
+ if (text !== null) {
353
+ event.preventDefault();
354
+ this.messageInput.value = text;
355
+ this.autoResizeTextarea();
356
+ // Place cursor at end
357
+ this.messageInput.setSelectionRange(text.length, text.length);
358
+ }
359
+ return;
312
360
  }
313
361
  });
314
362
 
@@ -405,6 +453,8 @@ class AuditariaWebClient {
405
453
 
406
454
  // Send message with attachments (but not for slash commands)
407
455
  if (this.wsManager.sendUserMessage(message, isSlashCommand ? [] : this.attachments)) {
456
+ // Track input for ArrowUp/Down history
457
+ this.inputHistory.addInput(message);
408
458
  this.messageInput.value = '';
409
459
  // Only clear attachments if we're not sending a slash command
410
460
  if (!isSlashCommand) {
@@ -1106,21 +1156,39 @@ class AuditariaWebClient {
1106
1156
  const section = document.createElement('div');
1107
1157
  section.className = 'web-footer-model-menu-section';
1108
1158
  const isCodexGroup = group.id === 'codex';
1159
+ const isAvailable = group.available !== false; // AUDITARIA_PROVIDER_AVAILABILITY: Default to true for backwards compatibility
1109
1160
 
1110
1161
  const title = document.createElement('div');
1111
1162
  title.className = 'web-footer-model-menu-title';
1112
1163
  title.textContent = group.label;
1113
1164
  section.append(title);
1114
1165
 
1166
+ // AUDITARIA_PROVIDER_AVAILABILITY: Show install message for unavailable providers
1167
+ if (!isAvailable && group.installMessage) {
1168
+ const installMsg = document.createElement('div');
1169
+ installMsg.className = 'web-footer-model-menu-install-message';
1170
+ installMsg.textContent = group.installMessage;
1171
+ section.append(installMsg);
1172
+ }
1173
+
1115
1174
  for (const option of group.options || []) {
1116
1175
  const item = document.createElement('div');
1117
1176
  item.className = 'web-footer-model-menu-item';
1118
- item.setAttribute('role', 'button');
1119
- item.setAttribute('tabindex', '0');
1177
+
1178
+ // AUDITARIA_PROVIDER_AVAILABILITY: Disable unavailable providers
1179
+ if (!isAvailable) {
1180
+ item.classList.add('is-disabled');
1181
+ item.setAttribute('aria-disabled', 'true');
1182
+ item.title = `${option.label} (not available - install required)`;
1183
+ } else {
1184
+ item.setAttribute('role', 'button');
1185
+ item.setAttribute('tabindex', '0');
1186
+ item.title = option.description || option.label;
1187
+ }
1188
+
1120
1189
  if (option.selection === this.modelMenuData.activeSelection) {
1121
1190
  item.classList.add('is-active');
1122
1191
  }
1123
- item.title = option.description || option.label;
1124
1192
  if (isCodexGroup) {
1125
1193
  item.classList.add('web-footer-model-menu-item-codex');
1126
1194
  }
@@ -1231,6 +1299,11 @@ class AuditariaWebClient {
1231
1299
  }
1232
1300
 
1233
1301
  item.addEventListener('click', () => {
1302
+ // AUDITARIA_PROVIDER_AVAILABILITY: Prevent selection of unavailable providers
1303
+ if (!isAvailable) {
1304
+ return;
1305
+ }
1306
+
1234
1307
  if (isCodexGroup) {
1235
1308
  const state = this.getCodexEffortStateForSelection(option.selection);
1236
1309
  this.wsManager.sendModelSelection(
@@ -1492,6 +1565,26 @@ class AuditariaWebClient {
1492
1565
  this.messageInput.style.height = 'auto';
1493
1566
  this.messageInput.style.height = Math.min(this.messageInput.scrollHeight, 120) + 'px';
1494
1567
  }
1568
+
1569
+ /**
1570
+ * Returns true when the caret is on the first line of the textarea
1571
+ * (or the textarea is empty), so ArrowUp should navigate history.
1572
+ */
1573
+ _cursorOnFirstLine() {
1574
+ const { value, selectionStart } = this.messageInput;
1575
+ // On first line if no newline before the cursor
1576
+ return value.substring(0, selectionStart).indexOf('\n') === -1;
1577
+ }
1578
+
1579
+ /**
1580
+ * Returns true when the caret is on the last line of the textarea
1581
+ * (or the textarea is empty), so ArrowDown should navigate history.
1582
+ */
1583
+ _cursorOnLastLine() {
1584
+ const { value, selectionStart } = this.messageInput;
1585
+ // On last line if no newline after the cursor
1586
+ return value.substring(selectionStart).indexOf('\n') === -1;
1587
+ }
1495
1588
 
1496
1589
  handleConfirmationResponse(callId, outcome, payload) {
1497
1590
  this.wsManager.sendConfirmationResponse(callId, outcome, payload);
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Thacio
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { ContextMenu } from './ContextMenu.js';
8
+
9
+ /**
10
+ * DiffContextMenu — right-click context menu for diff editors.
11
+ *
12
+ * Attaches to a Monaco diff editor's modified side.
13
+ * When the cursor is on a changed hunk:
14
+ * - Shows "Revert This Change", "Copy Original", "Revert All Changes"
15
+ * When the cursor is NOT on a hunk:
16
+ * - Falls through to Monaco's native context menu
17
+ *
18
+ * All reverts use executeEdits() so Ctrl+Z works.
19
+ */
20
+ export class DiffContextMenu {
21
+ /**
22
+ * @param {object} diffEditor - Monaco IDiffEditor instance
23
+ * @param {object} monaco - Monaco namespace (for Range, etc.)
24
+ */
25
+ constructor(diffEditor, monaco) {
26
+ this.diffEditor = diffEditor;
27
+ this.monaco = monaco;
28
+ this.contextMenu = new ContextMenu();
29
+ this.disposables = [];
30
+
31
+ this.attach();
32
+ }
33
+
34
+ /**
35
+ * Attach the context menu listener to the modified (right) editor
36
+ */
37
+ attach() {
38
+ const modifiedEditor = this.diffEditor.getModifiedEditor();
39
+
40
+ const disposable = modifiedEditor.onContextMenu((e) => {
41
+ const position = e.target?.position;
42
+ if (!position) return;
43
+
44
+ const hunk = this.findHunkAtLine(position.lineNumber);
45
+ if (!hunk) {
46
+ // Not on a hunk — let Monaco's native context menu handle it
47
+ return;
48
+ }
49
+
50
+ // On a changed hunk — suppress Monaco's menu and show ours
51
+ e.event.preventDefault();
52
+ e.event.stopPropagation();
53
+
54
+ const items = this.buildMenuItems(hunk);
55
+ this.contextMenu.show(
56
+ e.event.posx ?? e.event.clientX ?? e.event.browserEvent?.clientX ?? 0,
57
+ e.event.posy ?? e.event.clientY ?? e.event.browserEvent?.clientY ?? 0,
58
+ items
59
+ );
60
+ });
61
+
62
+ this.disposables.push(disposable);
63
+ }
64
+
65
+ /**
66
+ * Find the diff hunk containing the given line number (in the modified editor)
67
+ * @param {number} lineNumber
68
+ * @returns {object|null} The matching ILineChange or null
69
+ */
70
+ findHunkAtLine(lineNumber) {
71
+ const changes = this.diffEditor.getLineChanges();
72
+ if (!changes) return null;
73
+
74
+ for (const change of changes) {
75
+ // Modification or insertion: modifiedEndLineNumber > 0
76
+ if (change.modifiedEndLineNumber > 0) {
77
+ if (lineNumber >= change.modifiedStartLineNumber &&
78
+ lineNumber <= change.modifiedEndLineNumber) {
79
+ return change;
80
+ }
81
+ }
82
+ // Deletion: modifiedEndLineNumber === 0, lines only exist in original
83
+ // The deletion marker sits at modifiedStartLineNumber (the line after which content was deleted)
84
+ else if (change.modifiedEndLineNumber === 0) {
85
+ if (lineNumber === change.modifiedStartLineNumber ||
86
+ lineNumber === change.modifiedStartLineNumber + 1) {
87
+ return change;
88
+ }
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Build context menu items for a hunk
97
+ * @param {object} hunk - ILineChange
98
+ * @returns {Array} Menu items
99
+ */
100
+ buildMenuItems(hunk) {
101
+ const items = [
102
+ {
103
+ label: 'Revert This Change',
104
+ icon: 'codicon codicon-discard',
105
+ action: () => this.revertHunk(hunk)
106
+ },
107
+ {
108
+ label: 'Copy Original',
109
+ icon: 'codicon codicon-copy',
110
+ action: () => this.copyOriginal(hunk)
111
+ },
112
+ { separator: true },
113
+ {
114
+ label: 'Revert All Changes',
115
+ icon: 'codicon codicon-clear-all',
116
+ action: () => this.revertAll()
117
+ }
118
+ ];
119
+
120
+ return items;
121
+ }
122
+
123
+ /**
124
+ * Revert a single hunk: replace modified lines with original lines.
125
+ * Uses executeEdits for undo stack integration (Ctrl+Z works).
126
+ * @param {object} hunk - ILineChange
127
+ */
128
+ revertHunk(hunk) {
129
+ const { Range } = this.monaco;
130
+ const modifiedEditor = this.diffEditor.getModifiedEditor();
131
+ const modifiedModel = modifiedEditor.getModel();
132
+ const originalModel = this.diffEditor.getOriginalEditor().getModel();
133
+
134
+ if (!modifiedModel || !originalModel) return;
135
+
136
+ let editRange;
137
+ let originalText;
138
+
139
+ if (hunk.originalEndLineNumber === 0) {
140
+ // Pure insertion — content only in modified, nothing in original.
141
+ // Delete the inserted lines from modified.
142
+ const lastCol = modifiedModel.getLineMaxColumn(hunk.modifiedEndLineNumber);
143
+
144
+ if (hunk.modifiedStartLineNumber === 1) {
145
+ // Insertion at very beginning — delete lines and the trailing newline
146
+ const nextLineExists = hunk.modifiedEndLineNumber < modifiedModel.getLineCount();
147
+ editRange = new Range(
148
+ 1, 1,
149
+ nextLineExists ? hunk.modifiedEndLineNumber + 1 : hunk.modifiedEndLineNumber,
150
+ nextLineExists ? 1 : lastCol
151
+ );
152
+ } else {
153
+ // Delete from end of previous line (to eat the newline) through end of last inserted line
154
+ const prevLineLastCol = modifiedModel.getLineMaxColumn(hunk.modifiedStartLineNumber - 1);
155
+ editRange = new Range(
156
+ hunk.modifiedStartLineNumber - 1, prevLineLastCol,
157
+ hunk.modifiedEndLineNumber, lastCol
158
+ );
159
+ }
160
+
161
+ originalText = '';
162
+ } else if (hunk.modifiedEndLineNumber === 0) {
163
+ // Pure deletion — content only in original, nothing in modified.
164
+ // Insert the original lines into modified.
165
+ const origLastCol = originalModel.getLineMaxColumn(hunk.originalEndLineNumber);
166
+ originalText = originalModel.getValueInRange(
167
+ new Range(hunk.originalStartLineNumber, 1, hunk.originalEndLineNumber, origLastCol)
168
+ );
169
+
170
+ // Insert after modifiedStartLineNumber (the deletion marker line)
171
+ const insertLine = hunk.modifiedStartLineNumber;
172
+ const insertCol = modifiedModel.getLineMaxColumn(insertLine);
173
+ editRange = new Range(insertLine, insertCol, insertLine, insertCol);
174
+ originalText = '\n' + originalText;
175
+ } else {
176
+ // Modification — replace modified lines with original lines
177
+ const origLastCol = originalModel.getLineMaxColumn(hunk.originalEndLineNumber);
178
+ originalText = originalModel.getValueInRange(
179
+ new Range(hunk.originalStartLineNumber, 1, hunk.originalEndLineNumber, origLastCol)
180
+ );
181
+
182
+ const modLastCol = modifiedModel.getLineMaxColumn(hunk.modifiedEndLineNumber);
183
+ editRange = new Range(
184
+ hunk.modifiedStartLineNumber, 1,
185
+ hunk.modifiedEndLineNumber, modLastCol
186
+ );
187
+ }
188
+
189
+ modifiedEditor.executeEdits('revert-change', [{
190
+ range: editRange,
191
+ text: originalText
192
+ }]);
193
+ }
194
+
195
+ /**
196
+ * Copy the original (left-side) content of a hunk to clipboard
197
+ * @param {object} hunk - ILineChange
198
+ */
199
+ copyOriginal(hunk) {
200
+ const { Range } = this.monaco;
201
+ const originalModel = this.diffEditor.getOriginalEditor().getModel();
202
+ if (!originalModel) return;
203
+
204
+ if (hunk.originalEndLineNumber === 0) {
205
+ // Pure insertion — no original content to copy
206
+ return;
207
+ }
208
+
209
+ const origLastCol = originalModel.getLineMaxColumn(hunk.originalEndLineNumber);
210
+ const text = originalModel.getValueInRange(
211
+ new Range(hunk.originalStartLineNumber, 1, hunk.originalEndLineNumber, origLastCol)
212
+ );
213
+
214
+ navigator.clipboard.writeText(text).catch((err) => {
215
+ console.warn('Failed to copy to clipboard:', err);
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Revert all changes: replace entire modified content with original content
221
+ */
222
+ revertAll() {
223
+ const modifiedEditor = this.diffEditor.getModifiedEditor();
224
+ const modifiedModel = modifiedEditor.getModel();
225
+ const originalModel = this.diffEditor.getOriginalEditor().getModel();
226
+
227
+ if (!modifiedModel || !originalModel) return;
228
+
229
+ const originalText = originalModel.getValue();
230
+ const fullRange = modifiedModel.getFullModelRange();
231
+
232
+ modifiedEditor.executeEdits('revert-all', [{
233
+ range: fullRange,
234
+ text: originalText
235
+ }]);
236
+ }
237
+
238
+ /**
239
+ * Dispose all listeners and the context menu
240
+ */
241
+ dispose() {
242
+ for (const d of this.disposables) {
243
+ d.dispose();
244
+ }
245
+ this.disposables = [];
246
+
247
+ if (this.contextMenu) {
248
+ this.contextMenu.destroy();
249
+ this.contextMenu = null;
250
+ }
251
+ }
252
+ }