From 7d8f58690f427a92a817100e949c1900187c4ad3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 9 Jun 2026 22:27:08 +0200 Subject: [PATCH] number-key + Enter shortcuts for interactive prompt buttons (T-240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a ToolPromptCard, number keys pick the matching button/option (labels are prefixed 1./2./3.…) and Enter confirms the primary action — matching the Claude CLI. Permission: 1=Allow, 2=Allow&remember (when offered) else Deny, 3=Deny. AskUserQuestion: 1..N select/toggle the current question's options + Other. The card autofocuses and the key handler self-guards on hasPrimaryFocus, so once the user clicks into a note field the digits type normally and never fire a button. Shared the permission/option actions between the buttons and the keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 76 ++++++++++ .pql/changelog/tickets/2026-06.sql | 66 +++++++++ CHANGELOG.md | 5 + lib/builtin/claude/src/prompt_card.dart | 165 ++++++++++++++++------ test/builtin/claude/prompt_card_test.dart | 96 +++++++++++-- 5 files changed, 357 insertions(+), 51 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index e63fa59f..255aa1e7 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -2124,3 +2124,79 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, - Pairs with T-300 (clickable file references) — same conversation/markdown render path; likely share path-detection + workspace-root resolution.', NULL, '2026-06-09 20:04:54', '2026-06-09 20:04:54', '2026-06-09 20:04:54', NULL, '05fb96e25be6d666d0e259f45ada35a5', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-242', 'description', NULL, 'VERIFIED DONE 2026-06-09: failures-only is the default reporter across all gate scripts — ci/test.sh (REPORTER var, used in every flutter/dart test invocation), ci/test_core.sh, ci/test_a11y.sh — each with a TEST_REPORTER=expanded escape hatch for debugging. No work remaining.', NULL, '2026-06-09 20:07:29', '2026-06-09 20:07:29', '2026-06-09 20:07:29', NULL, '5a4b91ffd41fde8bca92457d539e2bd5', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-242', 'status', 'ready', 'done', NULL, '2026-06-09 20:07:30', '2026-06-09 20:07:30', '2026-06-09 20:07:30', NULL, '98793f24ffa8ce23f574b1ddd702a476', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-253', 'description', 'The Claude conversation panel detects URLs and colorizes them, but they are not interactive. Make detected links clickable (a plain click, or control/cmd-click) so they are handed off to the OS URL opener. + +## Behaviour +- Click (or ctrl/cmd-click) on a colorized link opens it via the OS default handler. +- Hover affordance (cursor change / underline) so it reads as clickable. +- Keep the existing colorization. + +## Notes +- Honour user/Claude parity (D-6) where relevant. +- Use the platform URL launcher; avoid pulling in an opinionated package if a thin native/url_launcher shim already exists in the tree. +- Guard against non-http schemes / malformed URLs.', 'The Claude conversation panel detects URLs and colorizes them, but they are not interactive. Make detected links clickable (a plain click, or control/cmd-click) so they are handed off to the OS URL opener. + +## Behaviour +- Click (or ctrl/cmd-click) on a colorized link opens it via the OS default handler. +- Hover affordance (cursor change / underline) so it reads as clickable. +- Keep the existing colorization. + +## Notes +- Honour user/Claude parity (D-6) where relevant. +- Use the platform URL launcher; avoid pulling in an opinionated package if a thin native/url_launcher shim already exists in the tree. +- Guard against non-http schemes / malformed URLs. + +DONE 2026-06-09. http(s) links in the conversation open via OsBridge.openURL on click (hover underline + pointer); non-http inert. ClideMarkdown gained an onLinkTap hook + _urlLinkSpan; the 3 inline-interaction callbacks were bundled into ClideMarkdownHooks (single threaded param) — also fixed links/images only working in some markdown contexts. Tests: clide_markdown_test.', NULL, '2026-06-09 20:17:04', '2026-06-09 20:17:04', '2026-06-09 20:17:04', NULL, '52aeaf560871a3f62a3790d3ad0946dc', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-253', 'status', 'ready', 'done', NULL, '2026-06-09 20:17:04', '2026-06-09 20:17:04', '2026-06-09 20:17:04', NULL, '316ad39ac68ed299afeaed9e74c46387', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-302', 'parent_id', NULL, 'T-8', NULL, '2026-06-09 20:24:57', '2026-06-09 20:24:57', '2026-06-09 20:24:57', NULL, 'd2dbf772252ce008d7c7e59bdd5f06d7', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-302', 'description', NULL, 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation. + +**Deliverable** +- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review. +- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete. + +**Scope to frame (from T-8)** +- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`. +- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?). +- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290). +- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI). + +**Open design questions for the wireframe to answer** +- Settings as a full-screen view, a pane/tab, or a modal? +- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched? +- Search/filter across all settings. +- How schema-driven fields render labels, help text, defaults, and reset. + +**Constraints** +- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino). + +This is the design step; implementation of the actual settings UI is separate child work under T-8.', NULL, '2026-06-09 20:25:49', '2026-06-09 20:25:49', '2026-06-09 20:25:49', NULL, '4b1fefa0723073ca71fdb99cb319f424', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-287', 'description', 'On Linux desktop startup, two GLib-GIO-CRITICAL warnings fire to stderr: + + GLib-GIO-CRITICAL: GFileInfo created without standard::size + file ../gio/gfileinfo.c: line 1865 (g_file_info_get_size): should not be reached + +Repro: launch the app (make run / flutter run) on Linux. The pair fires early in boot — right after the primary pane binds its session and before the IPC server starts listening (observed ~0.3s apart, e.g. 15:53:15.570 and 15:53:15.832), on every launch. + +Cause: some code path calls g_file_info_get_size() on a GFileInfo that was created/queried WITHOUT requesting the G_FILE_ATTRIBUTE_STANDARD_SIZE (''standard::size'') attribute. A grep of clide''s own code (lib/, linux/runner/, native/) finds no direct g_file_info / g_file_query_info usage, so it is most likely inside GTK/GLib itself or a Flutter Linux plugin''s file enumeration (icon/thumbnail/mime probe, path lookups), not clide Dart/C++. The GTK file-chooser in linux/runner/clide_app.cc is on-demand only, so it is not the trigger (the warnings fire at boot). + +Impact: low — console noise at CRITICAL level; no observed functional breakage. But a size query that ''should not be reached'' may be reading a bogus/zero size somewhere worth confirming. + +Investigation: run with G_DEBUG=fatal-warnings (or gdb break on g_log/g_logv) to capture the stack at the warning and identify the library/plugin frame; check whether a Flutter plugin (file_selector, path_provider, url_launcher) or GTK icon/mime loading is responsible. If upstream/GTK, document + suppress-from-our-side or pin; if a plugin, file upstream. + +Env: Fedora, GTK Linux embedder, flutter run.', 'On Linux desktop startup, two GLib-GIO-CRITICAL warnings fire to stderr: + + GLib-GIO-CRITICAL: GFileInfo created without standard::size + file ../gio/gfileinfo.c: line 1865 (g_file_info_get_size): should not be reached + +Repro: launch the app (make run / flutter run) on Linux. The pair fires early in boot — right after the primary pane binds its session and before the IPC server starts listening (observed ~0.3s apart, e.g. 15:53:15.570 and 15:53:15.832), on every launch. + +Cause: some code path calls g_file_info_get_size() on a GFileInfo that was created/queried WITHOUT requesting the G_FILE_ATTRIBUTE_STANDARD_SIZE (''standard::size'') attribute. A grep of clide''s own code (lib/, linux/runner/, native/) finds no direct g_file_info / g_file_query_info usage, so it is most likely inside GTK/GLib itself or a Flutter Linux plugin''s file enumeration (icon/thumbnail/mime probe, path lookups), not clide Dart/C++. The GTK file-chooser in linux/runner/clide_app.cc is on-demand only, so it is not the trigger (the warnings fire at boot). + +Impact: low — console noise at CRITICAL level; no observed functional breakage. But a size query that ''should not be reached'' may be reading a bogus/zero size somewhere worth confirming. + +Investigation: run with G_DEBUG=fatal-warnings (or gdb break on g_log/g_logv) to capture the stack at the warning and identify the library/plugin frame; check whether a Flutter plugin (file_selector, path_provider, url_launcher) or GTK icon/mime loading is responsible. If upstream/GTK, document + suppress-from-our-side or pin; if a plugin, file upstream. + +Env: Fedora, GTK Linux embedder, flutter run. + +UPDATE (2026-06-09): also fires MID-SESSION, not only at boot — contradicts the "fires early in boot, on every launch" framing above. Observed log: app booted 16:47:01, but the GLib-GIO-CRITICAL pair fired at 18:51:07.841 / 18:51:08.190 (~2h into the session), near pane/session activity. So the trigger is more likely a file-info code path tied to a user action or background file enumeration than pure startup. Re-scope the investigation to capture the stack when it fires mid-session (G_DEBUG=fatal-warnings / gdb break on g_log) rather than only at boot.', NULL, '2026-06-09 20:26:44', '2026-06-09 20:26:44', '2026-06-09 20:26:44', NULL, '0686bc37e43fe2adb5e54d6628abbd24', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 36de6f8c..1c1be8bf 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -3544,3 +3544,69 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, - Pairs with T-300 (clickable file references) — same conversation/markdown render path; likely share path-detection + workspace-root resolution.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:04:29', '2026-06-09 20:04:54', NULL, 'fdf0a8829db09aeb92850695246e407b', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-242', 'task', NULL, 'Quiet test reporter: use failures-only in ci scripts (only failures + final count)', 'VERIFIED DONE 2026-06-09: failures-only is the default reporter across all gate scripts — ci/test.sh (REPORTER var, used in every flutter/dart test invocation), ci/test_core.sh, ci/test_a11y.sh — each with a TEST_REPORTER=expanded escape hatch for debugging. No work remaining.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-05 10:17:47', '2026-06-09 20:07:29', NULL, 'd203990cf8add84bd60e3d4b09074c88', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-242', 'task', NULL, 'Quiet test reporter: use failures-only in ci scripts (only failures + final count)', 'VERIFIED DONE 2026-06-09: failures-only is the default reporter across all gate scripts — ci/test.sh (REPORTER var, used in every flutter/dart test invocation), ci/test_core.sh, ci/test_a11y.sh — each with a TEST_REPORTER=expanded escape hatch for debugging. No work remaining.', 'done', 'medium', NULL, NULL, NULL, '2026-06-05 10:17:47', '2026-06-09 20:07:30', NULL, 'e1918868d0229214cf1175e3c4a2a6a7', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-253', 'story', NULL, 'Make links in the Claude conversation panel clickable to open in OS URL handler', 'The Claude conversation panel detects URLs and colorizes them, but they are not interactive. Make detected links clickable (a plain click, or control/cmd-click) so they are handed off to the OS URL opener. + +## Behaviour +- Click (or ctrl/cmd-click) on a colorized link opens it via the OS default handler. +- Hover affordance (cursor change / underline) so it reads as clickable. +- Keep the existing colorization. + +## Notes +- Honour user/Claude parity (D-6) where relevant. +- Use the platform URL launcher; avoid pulling in an opinionated package if a thin native/url_launcher shim already exists in the tree. +- Guard against non-http schemes / malformed URLs. + +DONE 2026-06-09. http(s) links in the conversation open via OsBridge.openURL on click (hover underline + pointer); non-http inert. ClideMarkdown gained an onLinkTap hook + _urlLinkSpan; the 3 inline-interaction callbacks were bundled into ClideMarkdownHooks (single threaded param) — also fixed links/images only working in some markdown contexts. Tests: clide_markdown_test.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:53', '2026-06-09 20:17:04', NULL, '50b60ff602eaa27cb4d3025d7368d2d1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-253', 'story', NULL, 'Make links in the Claude conversation panel clickable to open in OS URL handler', 'The Claude conversation panel detects URLs and colorizes them, but they are not interactive. Make detected links clickable (a plain click, or control/cmd-click) so they are handed off to the OS URL opener. + +## Behaviour +- Click (or ctrl/cmd-click) on a colorized link opens it via the OS default handler. +- Hover affordance (cursor change / underline) so it reads as clickable. +- Keep the existing colorization. + +## Notes +- Honour user/Claude parity (D-6) where relevant. +- Use the platform URL launcher; avoid pulling in an opinionated package if a thin native/url_launcher shim already exists in the tree. +- Guard against non-http schemes / malformed URLs. + +DONE 2026-06-09. http(s) links in the conversation open via OsBridge.openURL on click (hover underline + pointer); non-http inert. ClideMarkdown gained an onLinkTap hook + _urlLinkSpan; the 3 inline-interaction callbacks were bundled into ClideMarkdownHooks (single threaded param) — also fixed links/images only working in some markdown contexts. Tests: clide_markdown_test.', 'done', 'medium', NULL, NULL, NULL, '2026-06-06 09:32:53', '2026-06-09 20:17:04', NULL, '2a72ffcad16186e61da544faf08511e9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-302', 'task', NULL, 'Frame0 wireframe: settings screen design', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-09 20:24:54', NULL, '9feecb7a07a9bbbdd48db1e4c0c59b8e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-302', 'task', 'T-8', 'Frame0 wireframe: settings screen design', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-09 20:24:57', NULL, '29fbe1e10f41cd177653c1f8784b6b44', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-302', 'task', 'T-8', 'Frame0 wireframe: settings screen design', 'Design the app''s settings screen as a Frame0 wireframe before building `builtin.settings-ui` (see Tier 6 epic T-8). Output is a wireframe to align on layout/IA, not implementation. + +**Deliverable** +- Frame0 wireframe authored via the frame0-wireframe skill: local JSON source-of-truth synced to Frame0, exported for review. +- Covers the settings screen shell + at least one fully-rendered category so the form-field patterns are concrete. + +**Scope to frame (from T-8)** +- Schema-driven panel: form fields keyed off the schema each subsystem registers against the kernel SettingsStore; edits write back to `.clide/settings.yaml`. +- Navigation/IA: how categories are grouped and selected (sidebar list? sections? search?). +- Field types to mock: toggle, enum/select (e.g. keymap preset), text/number, and a ''opens external file'' affordance (e.g. editor `.editorconfig` per T-290). +- Known consumers to account for: keymap preset switching (T-115/T-64/T-65/T-66), editor settings (T-290), activity fold level (T-183), theme picker (Tier 6 theming UI). + +**Open design questions for the wireframe to answer** +- Settings as a full-screen view, a pane/tab, or a modal? +- Per-project (`.clide/settings.yaml`) vs. user-global scope — shown together or switched? +- Search/filter across all settings. +- How schema-driven fields render labels, help text, defaults, and reset. + +**Constraints** +- Follow clide visual language — pull theme tokens / control geometry from the ui-design skill so the wireframe maps cleanly to real widgets (no Material/Cupertino). + +This is the design step; implementation of the actual settings UI is separate child work under T-8.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-09 20:25:49', NULL, '02c7fd8860f35097fffe573bcfab2739', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-287', 'bug', NULL, 'GLib-GIO-CRITICAL: GFileInfo created without standard::size on startup (Linux)', 'On Linux desktop startup, two GLib-GIO-CRITICAL warnings fire to stderr: + + GLib-GIO-CRITICAL: GFileInfo created without standard::size + file ../gio/gfileinfo.c: line 1865 (g_file_info_get_size): should not be reached + +Repro: launch the app (make run / flutter run) on Linux. The pair fires early in boot — right after the primary pane binds its session and before the IPC server starts listening (observed ~0.3s apart, e.g. 15:53:15.570 and 15:53:15.832), on every launch. + +Cause: some code path calls g_file_info_get_size() on a GFileInfo that was created/queried WITHOUT requesting the G_FILE_ATTRIBUTE_STANDARD_SIZE (''standard::size'') attribute. A grep of clide''s own code (lib/, linux/runner/, native/) finds no direct g_file_info / g_file_query_info usage, so it is most likely inside GTK/GLib itself or a Flutter Linux plugin''s file enumeration (icon/thumbnail/mime probe, path lookups), not clide Dart/C++. The GTK file-chooser in linux/runner/clide_app.cc is on-demand only, so it is not the trigger (the warnings fire at boot). + +Impact: low — console noise at CRITICAL level; no observed functional breakage. But a size query that ''should not be reached'' may be reading a bogus/zero size somewhere worth confirming. + +Investigation: run with G_DEBUG=fatal-warnings (or gdb break on g_log/g_logv) to capture the stack at the warning and identify the library/plugin frame; check whether a Flutter plugin (file_selector, path_provider, url_launcher) or GTK icon/mime loading is responsible. If upstream/GTK, document + suppress-from-our-side or pin; if a plugin, file upstream. + +Env: Fedora, GTK Linux embedder, flutter run. + +UPDATE (2026-06-09): also fires MID-SESSION, not only at boot — contradicts the "fires early in boot, on every launch" framing above. Observed log: app booted 16:47:01, but the GLib-GIO-CRITICAL pair fired at 18:51:07.841 / 18:51:08.190 (~2h into the session), near pane/session activity. So the trigger is more likely a file-info code path tied to a user action or background file enumeration than pure startup. Re-scope the investigation to capture the stack when it fires mid-session (G_DEBUG=fatal-warnings / gdb break on g_log) rather than only at boot.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-08 17:12:49', '2026-06-09 20:26:44', NULL, '23781a4f86b92056beb8a9fda34daaec', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index e48650e5..d6d876b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Number keys pick prompt buttons (CLI muscle memory).** In a permission or + AskUserQuestion prompt, `1`/`2`/`3`… select the matching button or option + (labels are now numbered), and Enter confirms the primary action. Typing in a + note field is unaffected — digits only act while the card itself holds focus. + (T-240) - **Links in the Claude conversation are clickable.** An http(s) link (typed or autolinked) now opens in your default browser on click — with a hover underline + pointer — across prose, lists, tables, and headings. Non-http diff --git a/lib/builtin/claude/src/prompt_card.dart b/lib/builtin/claude/src/prompt_card.dart index 52f1366f..34d5e2be 100644 --- a/lib/builtin/claude/src/prompt_card.dart +++ b/lib/builtin/claude/src/prompt_card.dart @@ -17,6 +17,7 @@ import 'package:clide/kernel/src/syntax/language_map.dart'; import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/tokens.dart'; import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; /// Sentinel option key for the always-present free-text "Other…" choice. @@ -93,39 +94,116 @@ class _ToolPromptCardState extends State { Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; final (accent, label, children) = widget.prompt.isQuestion ? _question(tokens) : _permission(tokens); - return Container( - width: double.infinity, - decoration: BoxDecoration( - color: tokens.panelBackground, - border: Border(top: BorderSide(color: accent, width: 2)), - ), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: accent), - const SizedBox(height: 8), - ...children, - ], + // Autofocus the card so number keys pick a button/option on appear (T-240). + // _onKey self-guards via hasPrimaryFocus, so once the user clicks a note + // field the digits type normally instead of triggering buttons. + return Focus( + autofocus: true, + onKeyEvent: _onKey, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border(top: BorderSide(color: accent, width: 2)), + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: accent), + const SizedBox(height: 8), + ...children, + ], + ), ), ); } + // -- number-key + Enter shortcuts (T-240, CLI muscle memory) --------------- + + static const _digitKeys = [ + LogicalKeyboardKey.digit1, + LogicalKeyboardKey.digit2, + LogicalKeyboardKey.digit3, + LogicalKeyboardKey.digit4, + LogicalKeyboardKey.digit5, + LogicalKeyboardKey.digit6, + LogicalKeyboardKey.digit7, + LogicalKeyboardKey.digit8, + LogicalKeyboardKey.digit9, + ]; + + KeyEventResult _onKey(FocusNode node, KeyEvent e) { + if (e is! KeyDownEvent) return KeyEventResult.ignored; + // A note field has focus → let the digits type; only act for the card. + if (!node.hasPrimaryFocus) return KeyEventResult.ignored; + final hw = HardwareKeyboard.instance; + if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored; + if (e.logicalKey == LogicalKeyboardKey.enter || e.logicalKey == LogicalKeyboardKey.numpadEnter) { + return _activatePrimary() ? KeyEventResult.handled : KeyEventResult.ignored; + } + final i = _digitKeys.indexOf(e.logicalKey); + if (i < 0) return KeyEventResult.ignored; + return _activateNumber(i + 1) ? KeyEventResult.handled : KeyEventResult.ignored; + } + + /// The question whose options the number keys address, or null (permission is + /// handled separately; the review step has no numbered options). + int? _currentQuestion() { + if (_q.isEmpty) return null; + if (_q.length <= 1) return 0; + return _step < _q.length ? _step : null; + } + + bool _activateNumber(int n) { + if (!widget.prompt.isQuestion) { + final canRemember = widget.prompt.permissionSuggestions.isNotEmpty; + if (n == 1) return _then(_permAllow); + if (canRemember && n == 2) return _then(() => _permAllow(remember: true)); + if (n == (canRemember ? 3 : 2)) return _then(_permDeny); + return false; + } + final qi = _currentQuestion(); + if (qi == null) return false; + final q = _q[qi]; + final keys = [...q.options.map((o) => o.label), _kOther]; + if (n < 1 || n > keys.length) return false; + _toggleOption(qi, keys[n - 1], q.multiSelect); + return true; + } + + /// Enter confirms the primary action: Allow (permission), or Submit / Next / + /// Review for questions — only when the step is answerable. + bool _activatePrimary() { + if (!widget.prompt.isQuestion) return _then(_permAllow); + if (_q.length <= 1) return (_q.isNotEmpty && _answer(0).isNotEmpty) ? _then(_submit) : false; + if (_step >= _q.length) return _allAnswered ? _then(_submit) : false; + return _answer(_step).isNotEmpty ? _then(() => setState(() => _step++)) : false; + } + + bool _then(void Function() action) { + action(); + return true; + } + // -- permission allow / allow-remember / deny ------------------------------ + String? _permNote() { + final t = _note.text.trim(); + return t.isEmpty ? null : t; + } + + void _permAllow({bool remember = false}) { + final p = widget.prompt; + widget.onResolve(p.promptId, AllowTool(p.input, updatedPermissions: remember ? p.permissionSuggestions : null, followUpNote: _permNote())); + } + + void _permDeny() => widget.onResolve(widget.prompt.promptId, DenyTool(_permNote() ?? 'Denied by the user.')); + (Color, String, List) _permission(SurfaceTokens tokens) { final p = widget.prompt; final canRemember = p.permissionSuggestions.isNotEmpty; - String? note() { - final t = _note.text.trim(); - return t.isEmpty ? null : t; - } - - void allow({bool remember = false}) => widget.onResolve( - p.promptId, - AllowTool(p.input, updatedPermissions: remember ? p.permissionSuggestions : null, followUpNote: note()), - ); return ( tokens.statusWarning, 'permission · ${p.displayName}', @@ -149,9 +227,9 @@ class _ToolPromptCardState extends State { spacing: 8, runSpacing: 8, children: [ - ClideButton(label: 'Allow', variant: ClideButtonVariant.primary, onPressed: () => allow()), - if (canRemember) ClideButton(label: "Allow & don't ask again", onPressed: () => allow(remember: true)), - ClideButton(label: 'Deny', onPressed: () => widget.onResolve(p.promptId, DenyTool(note() ?? 'Denied by the user.'))), + ClideButton(label: '1. Allow', variant: ClideButtonVariant.primary, onPressed: () => _permAllow()), + if (canRemember) ClideButton(label: "2. Allow & don't ask again", onPressed: () => _permAllow(remember: true)), + ClideButton(label: '${canRemember ? '3' : '2'}. Deny', onPressed: _permDeny), ], ), const SizedBox(height: 10), @@ -277,8 +355,9 @@ class _ToolPromptCardState extends State { spacing: 8, runSpacing: 8, children: [ - for (final opt in q.options) _optButton(qi, opt.label, opt.label, q.multiSelect, opt.description), - _optButton(qi, _kOther, 'Other…', q.multiSelect, ''), + for (var oi = 0; oi < q.options.length; oi++) + _optButton(qi, q.options[oi].label, q.options[oi].label, q.multiSelect, q.options[oi].description, oi + 1), + _optButton(qi, _kOther, 'Other…', q.multiSelect, '', q.options.length + 1), ], ), if (hasOther) ...[ @@ -291,25 +370,29 @@ class _ToolPromptCardState extends State { ); } - Widget _optButton(int qi, String key, String label, bool multi, String description) { + Widget _optButton(int qi, String key, String label, bool multi, String description, int number) { final sel = _picked[qi].contains(key); return ClideButton( - label: '${sel ? '●' : '○'} $label', + label: '$number. ${sel ? '●' : '○'} $label', variant: sel ? ClideButtonVariant.primary : ClideButtonVariant.subtle, tooltip: description.isNotEmpty ? description : null, - onPressed: () => setState(() { - final s = _picked[qi]; - if (multi) { - s.contains(key) ? s.remove(key) : s.add(key); - } else { - s - ..clear() - ..add(key); - } - }), + onPressed: () => _toggleOption(qi, key, multi), ); } + void _toggleOption(int qi, String key, bool multi) { + setState(() { + final s = _picked[qi]; + if (multi) { + s.contains(key) ? s.remove(key) : s.add(key); + } else { + s + ..clear() + ..add(key); + } + }); + } + Widget _chatInstead(SurfaceTokens tokens) { return ClideButton( label: 'chat instead', diff --git a/test/builtin/claude/prompt_card_test.dart b/test/builtin/claude/prompt_card_test.dart index bd2d2641..4d97731f 100644 --- a/test/builtin/claude/prompt_card_test.dart +++ b/test/builtin/claude/prompt_card_test.dart @@ -1,6 +1,7 @@ import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/stream_json_session.dart'; import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -84,10 +85,10 @@ void main() { await tester.pump(); expect(find.text('permission · Write'), findsOneWidget); - expect(find.text('Allow'), findsOneWidget); - expect(find.text('Deny'), findsOneWidget); + expect(find.text('1. Allow'), findsOneWidget); + expect(find.text('2. Deny'), findsOneWidget); - await tester.tap(find.text('Allow')); + await tester.tap(find.text('1. Allow')); await tester.pump(); expect(id, 'req-1'); @@ -188,7 +189,7 @@ void main() { )); await tester.pump(); - await tester.tap(find.text('Deny')); + await tester.tap(find.text('2. Deny')); await tester.pump(); expect(decision, isA()); @@ -198,7 +199,7 @@ void main() { testWidgets('permission: no "don\'t ask again" button without a suggestion', (tester) async { await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {}))); await tester.pump(); - expect(find.text("Allow & don't ask again"), findsNothing); + expect(find.text("2. Allow & don't ask again"), findsNothing); }); testWidgets('permission: "don\'t ask again" shows with a suggestion and returns updatedPermissions', (tester) async { @@ -212,8 +213,8 @@ void main() { )); await tester.pump(); - expect(find.text("Allow & don't ask again"), findsOneWidget); - await tester.tap(find.text("Allow & don't ask again")); + expect(find.text("2. Allow & don't ask again"), findsOneWidget); + await tester.tap(find.text("2. Allow & don't ask again")); await tester.pump(); expect((decision as AllowTool).updatedPermissions, hasLength(1)); }); @@ -224,7 +225,7 @@ void main() { await tester.pump(); await tester.enterText(find.byType(EditableText), 'write it under docs/ instead'); await tester.pump(); - await tester.tap(find.text('Deny')); + await tester.tap(find.text('2. Deny')); await tester.pump(); expect((decision as DenyTool).message, 'write it under docs/ instead'); }); @@ -235,7 +236,7 @@ void main() { await tester.pump(); await tester.enterText(find.byType(EditableText), 'fyi: sandbox only'); await tester.pump(); - await tester.tap(find.text('Allow')); + await tester.tap(find.text('1. Allow')); await tester.pump(); expect((decision as AllowTool).followUpNote, 'fyi: sandbox only'); }); @@ -289,7 +290,7 @@ void main() { await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d))); await tester.pump(); - await tester.tap(find.text('○ Other…')); + await tester.tap(find.textContaining('Other…')); await tester.pump(); // Two fields now: [0] = the Other free-text, [1] = the per-choice note. await tester.enterText(find.byType(EditableText).first, 'Kiwi'); @@ -472,4 +473,79 @@ void main() { expect((decision as AllowTool).updatedInput['answers']['New question?'], 'Alpha'); }); }); + + group('number-key + Enter shortcuts (T-240)', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Future pumpCard(WidgetTester tester, ToolPrompt p, void Function(ToolDecision) onDecide) async { + await tester.pumpWidget(harness(f, ToolPromptCard(prompt: p, onResolve: (_, d) => onDecide(d)))); + await tester.pump(); // let the card autofocus + } + + testWidgets('1 = Allow, 2 = Deny when there is no remember button', (tester) async { + ToolDecision? d; + await pumpCard(tester, permissionPrompt(), (x) => d = x); + await tester.sendKeyEvent(LogicalKeyboardKey.digit1); + await tester.pump(); + expect(d, isA()); + }); + + testWidgets('2 = Deny with no remember button', (tester) async { + ToolDecision? d; + await pumpCard(tester, permissionPrompt(), (x) => d = x); + await tester.sendKeyEvent(LogicalKeyboardKey.digit2); + await tester.pump(); + expect(d, isA()); + }); + + const sugg = [ + {'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'} + ]; + + testWidgets('with a remember suggestion: 2 = Allow & remember', (tester) async { + ToolDecision? d; + await pumpCard(tester, permissionPrompt(suggestions: sugg), (x) => d = x); + await tester.sendKeyEvent(LogicalKeyboardKey.digit2); + await tester.pump(); + expect((d as AllowTool).updatedPermissions, hasLength(1)); + }); + + testWidgets('with a remember suggestion: 3 = Deny', (tester) async { + ToolDecision? d; + await pumpCard(tester, permissionPrompt(suggestions: sugg), (x) => d = x); + await tester.sendKeyEvent(LogicalKeyboardKey.digit3); + await tester.pump(); + expect(d, isA()); + }); + + testWidgets('a number key selects a question option', (tester) async { + ToolDecision? d; + await pumpCard(tester, questionPrompt(), (x) => d = x); + await tester.sendKeyEvent(LogicalKeyboardKey.digit2); // Dogs (option 2) + await tester.pump(); + await tester.tap(find.text('Submit')); + await tester.pump(); + expect((d as AllowTool).updatedInput['answers']['Do you prefer cats or dogs?'], 'Dogs'); + }); + + testWidgets('Enter confirms the primary action (Allow)', (tester) async { + ToolDecision? d; + await pumpCard(tester, permissionPrompt(), (x) => d = x); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(d, isA()); + }); + + testWidgets('a focused note field swallows digits (no button fires)', (tester) async { + ToolDecision? d; + await pumpCard(tester, permissionPrompt(), (x) => d = x); + await tester.tap(find.byType(EditableText)); // focus the note field + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.digit1); + await tester.pump(); + expect(d, isNull, reason: 'typing in the note must not trigger Allow'); + }); + }); }