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]