Add Goal mode shortcut to chat composer
Add a Goal mode action to the composer popover that prefills the /goal command, focuses the input, and leaves sending to the user. Cover the prefill-only behavior with a focused plugin test.
Alessandro committed
Aug 19, 2026 at 04:50 UTC
cf1bec3ed9128883a76b83e771550d43bbec98c1
3 files changed
+78
-1
plugins/_goal/AGENTS.md
+2
-1
@@ -11,7 +11,7 @@
11
- `tools/goal.py` owns the single agent-facing goal tool, file-backed state under `usr/plugins/_goal/goals/`, and goal status normalization.
12
- `api/goal.py` owns the WebUI JSON API for reading, editing, pausing, resuming, and deleting goals.
13
- `commands/` owns the `/goal` slash command contributed to `_commands`.
14
-- `webui/` and `extensions/webui/` own the composer goal strip and inline controls.
14
+- `webui/` and `extensions/webui/` own the composer goal strip, Goal mode shortcut, and inline controls.
15
- `tools/goal.py` and `prompts/agent.system.tool.goal.md` own agent-facing goal inspection, creation, and status updates.
16
- `tools/response.py` overrides the core response tool so an active goal continues the current monologue.
17
- `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context.
@@ -24,6 +24,7 @@
24
- User controls may pause, resume, edit, or delete a goal; destructive delete uses inline confirmation. Model tools may create goals and mark them complete or blocked.
25
- Saving an edit that reactivates a complete or blocked goal resends the edited objective so agent processing resumes.
26
- `/goal <objective>` creates the goal and sends the objective as the user message so the agent starts working immediately.
27
+- The composer Goal mode shortcut only prefills `/goal ` and focuses the input; it never sends the command.
28
- `/goal auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
29
- While a goal is active, response-tool calls are intermediate updates; only completing or blocking the goal restores normal loop termination.
30
- Goal UI feedback uses toast notifications and inline controls, not modal dialogs.
plugins/_goal/extensions/webui/initFw_end/goal-menu-injector.js
new
+61
@@ -0,0 +1,61 @@
1
+import { store as chatInputStore } from "/components/chat/input/input-store.js";
2
+
3
+const MENU_SELECTOR = ".chat-bottom-actions-menu";
4
+const BUTTON_ID = "goal-chat-more-item";
5
+
6
+function buildButton() {
7
+ const button = document.createElement("button");
8
+ button.type = "button";
9
+ button.className = "chat-bottom-menu-item";
10
+ button.id = BUTTON_ID;
11
+ button.innerHTML = `
12
+ <x-icon aria-hidden="true" name="track_changes"></x-icon>
13
+ <span>Goal mode</span>
14
+ `;
15
+
16
+ button.addEventListener("click", () => {
17
+ chatInputStore.closeChatMoreMenu();
18
+ chatInputStore.message = "/goal ";
19
+ chatInputStore.adjustTextareaHeight();
20
+ chatInputStore.focus();
21
+ chatInputStore._setEditorCaret?.(chatInputStore.message.length);
22
+ });
23
+
24
+ return button;
25
+}
26
+
27
+function injectButton(menu) {
28
+ if (!(menu instanceof HTMLElement)) return;
29
+ if (menu.querySelector(`#${BUTTON_ID}`)) return;
30
+ menu.appendChild(buildButton());
31
+}
32
+
33
+function scan(root = document) {
34
+ for (const menu of root.querySelectorAll(MENU_SELECTOR)) {
35
+ injectButton(menu);
36
+ }
37
+}
38
+
39
+export default async function initGoalMenuInjector() {
40
+ scan();
41
+
42
+ const observer = new MutationObserver((mutations) => {
43
+ for (const mutation of mutations) {
44
+ for (const node of mutation.addedNodes) {
45
+ if (!(node instanceof Element)) continue;
46
+
47
+ if (node.matches?.(MENU_SELECTOR)) {
48
+ injectButton(node);
49
+ continue;
50
+ }
51
+
52
+ if (node.querySelectorAll) scan(node);
53
+ }
54
+ }
55
+ });
56
+
57
+ observer.observe(document.body, {
58
+ childList: true,
59
+ subtree: true,
60
+ });
61
+}
plugins/_goal/tests/test_goal_plugin.py
+15
@@ -111,6 +111,21 @@ def test_goal_webui_uses_state_revisions_instead_of_polling():
111
assert "goalStore.refresh(true)" in refresh
112
113
114
+def test_goal_composer_menu_prefills_without_sending():
115
+ plugin_root = Path(__file__).resolve().parents[1]
116
+ injector = (
117
+ plugin_root
118
+ / "extensions"
119
+ / "webui"
120
+ / "initFw_end"
121
+ / "goal-menu-injector.js"
122
+ ).read_text()
123
+
124
+ assert 'chatInputStore.message = "/goal ";' in injector
125
+ assert "chatInputStore.focus();" in injector
126
+ assert "sendMessage" not in injector
127
+
128
+
129
@pytest.mark.skipif(not shutil.which("node"), reason="node is required")
130
def test_goal_webui_uses_shared_hour_aware_duration_formatter():
131
project_root = Path(__file__).resolve().parents[3]