Skip to content

Settings menu in two declared pages, with the input box layout as a setting - #1304

Merged
trissim merged 8 commits into
mainfrom
work/toad-settings-menu
Oct 30, 2026
Merged

trissim merged 8 commits into
mainfrom
work/toad-settings-menu

Conversation

@trissim

@trissim trissim commented Oct 28, 2026 •

Copy link
Copy Markdown

Toad's settings menu now has two tabs, Interface and Agents and shell. Every tab, group, dropdown, toggle and number box is generated from the settings declarations. A new field or Choice family appears in the menu with no UI code. The input box now has a layout setting, Normal (classic, bordered and padded), Compact (the current look, still the default) or Flush, and the change applies live.

Depends on OpenHCSDev/formbridge#16, now merged. uv.lock is re-locked to formbridge main b67c97e5. The carry PR on agent-comms (work/toad-input-settings-carry) lands after this one (#3568, a draft until this merges). #3567 reverts the copy I pushed to agent-comms main by mistake.

The generator (one owner per fact)

  • Field semantics (label, help from the docstring, default, >= 1-style bounds from annotated_types, search text, validation, edit) live in formbridge/forms/declared_fields.py:DeclaredField, which a PyQt backend can also read. toad/forms/declared_views.py, field_path.py and kinds.py are deleted.
  • Tabs are FormPage metadata on each top-level group: ui: Annotated[UiSettings, ..., INTERFACE] (src/toad/preferences.py). formbridge.form_pages derives the tabs and their order. FormView.pages(state) (src/toad/forms/form_view.py) builds one form per page, and SettingsScreen (src/toad/screens/settings.py) draws a Tabs row over a ContentSwitcher. Search filters every page.
  • Widgets are picked by the DeclaredViews families. Choice families get dropdowns through FamilyChoices, bools get checkboxes, int and float get validated inputs that show their bounds, and nested dataclasses become sub-groups. The closed screen costs nothing because it is built only when opened.
  • Hot path: each prompt geometry property reads app.settings.ui.input.box.<attr>. ObjectState returns its cached object, measured at 0.14 µs per app.settings.ui.footer read (timeit, 2000 reads). There is no lookup chain.

Scoped (lazy) inheritance: none

I checked every setting for a real scope (app -> agent kind -> thread or tab). No consumer reads any setting per agent kind or per tab. Every read is app.settings.<group>.<field>, the one app-wide value. Adding ObjectState scope layers would add states nothing reads, so no setting gets scoped inheritance in this PR. The candidates if a scope appears later are agent.thoughts, tools.expand and shell.* (per agent kind). The page split is Interface vs Agents and shell, not scoped vs plain.

Numbers

  • Editable settings shown: 40 -> 43. Added ui.input.box, agent_symbol, multiline_symbol and shell_symbol. Moved prompt_message -> ui.input.placeholder. Deleted compact_input.
  • Hard-coded answers deleted:
    • 3 if compact_input branches (prompt.py AgentInfo.padding, InfoBar.margin, PromptLabel.padding);
    • 3 glyph constants (PromptLabel.AI/SHELL/MULTILINE);
    • 2 states_default member constants and their branch (NRA 91487a46b2);
    • 2 patch_preview member constants and their branch (NRA a425f587fa);
    • 2 unused FormView parameters (paths= and focus=, used nowhere).
  • Bugs fixed on the way:
    • prompt_message was declared and shown in the menu, but nothing read it. It is now the live placeholder.
    • title = "Success only" / "Fail only" / "When there are multiple sessions" / "When app is not focused" were dead: those members lacked TitledChoice, so the dropdowns said "Success", "Fail", "Multiple" and "Blur". They now show their titles (checked in the interpreter).

Inventory (AST scan of src/toad: 16 geometry, 15 glyph and 69 numeric constants, plus property branches)

Promoted:

site now
widgets/prompt.py:105 AgentInfo padding if compact_input InputBox.agent_info_padding
widgets/prompt.py:153 InfoBar margin if compact_input InputBox.info_bar_margin(footer)
widgets/prompt.py:161-167 PromptLabel glyphs ❯ $ ☰ and padding branch ui.input.agent_symbol / shell_symbol / multiline_symbol, InputBox.label_padding
preferences.py:87 prompt_message (dead) ui.input.placeholder, read in Prompt.update_prompt
preferences.py:90 compact_input ui.input.box (InputBox family in setting_choices.py)
PromptContainer had no frame at all InputBox.frame_border/frame_padding/frame_margin (Normal draws a round border)

Not promoted, with reasons:

  • Protocol/safety limits:

    • acp/permission_controller.py:12 PERMISSION_TIMEOUT_SECONDS
    • mcp_decision.py:28-29, mcp_inventory.py:20-21 timeouts and byte caps
    • comms_root.py:89 ROUTE_CHECK_SECONDS
    • acp/agent_session.py:135 reconnect_timeout_seconds
    • file_kind.py:16, clipboard_image.py byte caps

    These guard correctness, not taste.

  • Scroll and preparation budgets:

    • widgets/presentation_window.py:60-69 max_items, minimum_widgets, lookahead_seconds, scroll_idle_seconds
    • widgets/conversation.py:809 MAX_LIVE_BLOCKS
    • acp/projection_attachment.py:67 MAX_PREBIND
    • widgets/line_markdown.py:32 RESTYLE_INTERVAL
    • widgets/acp_log.py:35 FOLLOW_INTERVAL
    • fuzzy_index.py:96 MAX_CANDIDATES
    • work_preparation.py:44,50, transcript_preparation.py:226 sizes

    These are measured values on the p99.9 frame path, and CLAUDE.md requires review of scroll-path changes. history_buffer_viewports is already a setting.

  • Glyph sets:

    • acp/status.py:118,128 ⌛ ✔
    • line_blocks.py:101 −
    • conversation_actions.py:139 ␣
    • prompt_actions.py:41 ⏎
    • sidebar_layout.py:71,76 and widgets/side_bar.py:301,309 <── ──>
    • terminal_attention.py:112 🐸
    • textual_colours.py:126 GLYPHS

    These belong in one future GlyphSet choice (Unicode vs ASCII for fonts that lack them), not as per-glyph strings. Next step.

  • Candidate preferences left for a follow-up:

    • widgets/conversation.py:120 AUTO_SESSION_TITLE_MAX_LENGTH
    • widgets/session_details.py:42 DETAIL_ROWS
    • screens/welcome.py:85 LIMIT
    • wire_history.py:297-298 tail/page limits
    • widgets/terminal.py:29 ESCAPE_TAP_DURATION (keyboard timing; I kept clear of the keymap work, P887)
  • Design, not preference:

    • forms/* and screens/settings.py form geometry
    • widget_view.py:18-52 base defaults
    • widgets/session_tabs.py:43,179
    • ansi/_ansi.py:1302 terminal defaults
  • Colours and motion: left to the UX agent's work/toad-ux-motion. They plug in as Choice fields under ui and need no UI code here.

NRA

I used claude-nra's scan of Toad origin/main 5fa29f3 (NRA eca10589, 105 detectors, complete: true, 0 omitted, 340 findings):

  • 91487a46b2 (states_default) and a425f587fa (patch_preview): fixed by overrides on the family.
  • 1f92187de2 (hand_rolled_registration on DeclaredViews -> AutoRegisterMeta): declined. AutoRegisterMeta is an ABCMeta and Textual widgets use _MessagePumpMeta. class X(Widget, metaclass=AutoRegisterMeta) raises TypeError: metaclass conflict (checked in the interpreter). DeclaredViews also registers many keys per class, not 1:1.
  • The other 31 constant_subtype_answer families are runtime state identities (busy, ready, accepts_updates ...), not user preferences, so promoting them to settings would be wrong.
  • ruff --select UP037 on the touched files: nothing to fix.

Proof (headless Textual pilot, isolated XDG and comms root; SVGs and PNGs in /home/ts/.cache/agent-scratch/claude-code/toad-settings/)

  • before-main.png, before-settings.png: one long form, no tabs.
  • crops.png: the input box, from top to bottom: before, Compact (identical apart from the now-live placeholder), Normal (round bordered padded box), Flush (no spacing).
  • after-tabs-settings.png, after-page2-settings-keys.png: the two tabs. The second shows "Fail only" where it used to say "Fail".
  • after-search-settings-keys.png: searching "input" shows the Input box group with the Layout dropdown and bounds or default notes.
  • crop-live.png: the Layout set to Normal through the editor's own send while the app runs. The prompt re-lays out at once, and toad.json on dismiss holds {"ui":{"input":{"box":"normal"}}}.

Touches outside the core surface

  • src/toad/keymap.py: one import line (declared_fields moved). The keymap menu is otherwise untouched (P887 owns it).
  • Tests updated for the moved names: app.screen.values replaces app.screen.form.values; the test extension field names a page. I did not run them (no suites here); CI runs them.

Durable state

ui.compact-input and ui.prompt_message are retired keys, and settings decoding refuses unknown keys. The agent-comms carry tools/cutover/carry_toad_input_settings.py (branch work/toad-input-settings-carry) rewrites them once. compact-input: true becomes box: flush, and the message becomes the placeholder. I ran it on a sample file: dry run changed=1, apply changed=1, rerun changed=0, and the result decodes.

Correction after hosted debt (run 40718265703)

That run flagged two FunctionLocalImport hits in setting_effects.py (the new local Prompt imports in prompt_layout and prompt_text) and two UnannotatedParameter hits in setting_choices.py (the status parameter of each expands_preview).

  • Hoisting Prompt into setting_effects would create an import cycle (preferences imports setting_effects, and widgets.prompt imports preferences). So the two effects now live on PromptSettingEffects in the leaf module src/toad/setting_widget.py, next to SettingVisibility.
    • apply_input_layout and apply_input_text are static Effect objects. Class access returns the Effect itself, so SavedSettings still applies a change through the same single path: field.metadata(Effect).apply(app, field).
    • Each effect finds open prompts with Effect.open_widgets(app, PromptSettingEffects).
    • Prompt adds the capability to its class bases, and MessagePrompt inherits it.
    • setting_effects.prompt_layout and prompt_text are deleted, with no aliases.
  • Both expands_preview status parameters are now annotated ToolCallStatus, imported under TYPE_CHECKING only.
  • The three local imports already on main (setting_effects.py:18/41/58) are unchanged and are not counted as fixed.
  • Checks run (source probes only):
    • The order's check prints 2 (only the baseline Prompt and PromptTextArea imports remain).
    • A fresh import loads in all three orders: preferences first, prompt first, setting_effects first.
    • All five ui.input metadata entries are Effect objects: box is apply_input_layout, the other four are apply_input_text, and the two callbacks are distinct.
    • PromptSettingEffects appears once in the MRO of both Prompt and MessagePrompt.
    • No attribute name collides with one already on Prompt or Widget.
    • An AST census of src, tests and tools finds zero references to prompt_layout or prompt_text.
    • py_compile passes, and formbridge stays locked at b67c97e5.
  • Not run by me: the tests that cover this change (tests/textual/test_settings_screen.py, tests/textual/test_forms.py, tests/model/test_saved_settings.py, tests/textual/test_quit_with_unsaved_settings.py), and no hosted debt re-read. These probes do not prove paint, focus or save behaviour.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK

…etting

The settings screen shows one tab per FormPage the top-level groups
declare (Interface; Agents and shell), generated from the declarations
through formbridge. Field semantics move to formbridge.forms.declared_fields;
toad/forms keeps only the Textual widgets.

The compact_input flag, whose consumers each branched on it, becomes the
InputBox choice family (Normal, Compact, Flush); each prompt part reads its
room from the chosen member. The dead prompt_message setting becomes the
live input placeholder, and the three prompt symbols become settings.
Choices that declared a title without TitledChoice now show it, the
states_default and patch_preview member flags become overrides, and
numeric help states its declared bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK
Copilot AI balanced review requested due to automatic review settings October 28, 2026 15:37
@trissim
trissim enabled auto-merge October 28, 2026 15:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

Tristan Simas and others added 2 commits October 28, 2026 11:39
setting_effects imported Prompt inside two new effect functions because a
module-level import would cycle (preferences -> setting_effects, prompt ->
preferences). The effects now live on PromptSettingEffects in the leaf
setting_widget.py as static Effect metadata; Prompt (and MessagePrompt)
compose it, each effect enumerates open widgets of that capability, and
InputSettings names them directly. setting_effects.prompt_layout and
prompt_text are deleted. Both expands_preview status parameters are
annotated with ToolCallStatus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK
@trissim
trissim disabled auto-merge October 28, 2026 19:04
@trissim
trissim marked this pull request as draft October 28, 2026 19:04
trissim and others added 2 commits October 30, 2026 05:50
form_view.py keeps main's field refusal label and the branch's search text
on DeclaredField; settings.py keeps the declared pages and main's ToadNode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu
…as hidden

test_forms.py:131 still read app notifications after 961e7ca moved refusals
onto the field's own FieldRefusal; test_settings_screen.py:113 counted
ui.keymap.bindings as editable though Keymap is marked hidden as a class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu
@trissim
trissim marked this pull request as ready for review October 30, 2026 09:51
@trissim
trissim enabled auto-merge October 30, 2026 09:51
trissim and others added 2 commits October 30, 2026 05:58
…ts where no cycle

ToolCallStatus and DeclaredField import at run time; only ToadApp stays
behind TYPE_CHECKING in setting_widget.py, since toad.app imports it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu
EachOpenWidget (setting_declarations.py) owns running a method on every open
widget of the class it is declared on, so setting_widget.py no longer names
the app type and its TYPE_CHECKING import of toad.app is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu
@trissim
trissim merged commit 35770a0 into main Oct 30, 2026
1 of 3 checks passed
trissim added a commit to OpenHCSDev/agent-comms that referenced this pull request Oct 30, 2026
…#1304, 35770a0c2)

batrachian-toad 35770a0c, formbridge b67c97e5; the carry's toad.preferences
InputSettings and toad.setting_choices FlushInputBox now resolve, and every
cutover module imports against this lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants