feat(tui): implement SettingsScreen with search-driven navigation and persistence #1237

Closed
brent.edwards wants to merge 1 commits from feature/m8-tui-settings-screen into master
8 changed files with 775 additions and 3 deletions
+6
View File
@@ -30,6 +30,12 @@
the same path in a subprocess context. Tests simulate the divergent-
container condition (fresh ``CLEVERAGENTS_HOME`` with empty database).
ASV benchmark measures active-plan filtering overhead. (#1035)
- Implemented TUI SettingsScreen with `F2` / `ctrl+,` toggle, schema-driven
settings rendering, real-time search filtering, JSON persistence to
`~/.config/cleveragents/tui-settings.json`, and immediate in-session apply on
setting changes. Added a dedicated `SettingsStore` schema/persistence module,
a `SettingsOverlay` widget, and Behave coverage scenarios for open/search/
persistence/immediate-apply behavior. (#995)
- Added missing `LspServerConfig` model fields per specification:
`description` (max 1000 chars), `transport` (`LspTransport` enum with
`stdio`/`tcp`, default `stdio`), `initialization` (dict for LSP
+80
View File
@@ -13,6 +13,7 @@ These steps target uncovered lines in cleveragents/tui/app.py:
"""
import importlib
import json
import os
import shutil
import sys
@@ -119,12 +120,14 @@ def _install_mock_textual(context):
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.settings_overlay as so_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
importlib.reload(so_mod)
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
@@ -147,12 +150,14 @@ def _restore_modules(context):
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.settings_overlay as so_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
importlib.reload(so_mod)
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
@@ -273,6 +278,21 @@ def step_instantiate_app(context):
)
@given("a temporary settings file path for the app")
def step_create_settings_file_path(context):
context._tui_settings_path = Path(context._tui_tmpdir) / "tui-settings.json"
@when("I instantiate the Textual TUI app with settings path")
def step_instantiate_app_with_settings_path(context):
AppClass = context._tui_app_mod._ResolvedTuiApp
context._tui_app = AppClass(
command_router=context._tui_cmd_router,
persona_state=context._tui_persona_state,
settings_path=context._tui_settings_path,
)
@then('the app should have a _session with session_id "{sid}"')
def step_app_session_id(context, sid):
assert context._tui_app._session.session_id == sid
@@ -301,6 +321,11 @@ def step_bindings_count(context, count):
assert len(context._tui_app.BINDINGS) == count
@then('the app bindings should include key "{key}"')
def step_bindings_include_key(context, key):
assert any(binding[0] == key for binding in context._tui_app.BINDINGS)
# ---------------------------------------------------------------------------
# compose (lines 102-112)
# ---------------------------------------------------------------------------
@@ -510,3 +535,58 @@ def step_alias_check(context):
assert (
context._tui_app_mod.CleverAgentsTuiApp.__name__ == "_TextualCleverAgentsTuiApp"
)
@when("I call action_toggle_settings on the app")
def step_call_toggle_settings(context):
context._tui_app.action_toggle_settings()
@when("I call action_cycle_setting_value on the app")
def step_call_cycle_setting_value(context):
context._tui_app.action_cycle_setting_value()
def _settings_overlay_text(context) -> str:
from cleveragents.tui.widgets.settings_overlay import SettingsOverlay
overlay = context._tui_app.query_one("#settings-overlay", SettingsOverlay)
return getattr(overlay, "_text", "")
@then('the settings overlay should contain "{text}"')
def step_settings_overlay_contains(context, text):
rendered = _settings_overlay_text(context)
assert text in rendered, f"Expected '{text}' in settings overlay: {rendered}"
@then('the settings overlay should not contain "{text}"')
def step_settings_overlay_not_contains(context, text):
rendered = _settings_overlay_text(context)
assert text not in rendered, (
f"Did not expect '{text}' in settings overlay: {rendered}"
)
@then("the settings schema should include at least {count:d} settings")
def step_settings_schema_count(context, count):
definitions = context._tui_app._settings_store.definitions()
assert len(definitions) >= count
@then("the settings schema should include {count:d} groups")
def step_settings_group_count(context, count):
groups = context._tui_app._settings_store.group_names()
assert len(groups) == count
@then("the settings file should exist")
def step_settings_file_exists(context):
assert context._tui_settings_path.exists()
@then('the persisted settings should include key "{key}"')
def step_persisted_settings_contains_key(context, key):
with context._tui_settings_path.open(encoding="utf-8") as stream:
payload = json.load(stream)
assert key in payload
+55 -1
View File
@@ -47,7 +47,13 @@ Feature: TUI App Coverage
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app class should have CSS_PATH set to "cleveragents.tcss"
And the app class should have 3 key bindings
And the app class should have 8 key bindings
Scenario: The app class includes settings hotkeys
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app bindings should include key "f2"
And the app bindings should include key "ctrl+,"
# --- compose method (lines 102-112) ---
@@ -196,3 +202,51 @@ Feature: TUI App Coverage
And I call on_mount on the app
And I submit "!true" to the app
Then the conversation widget should contain "(empty output)"
# --- SettingsScreen hotkeys, schema, search, persistence ---
Scenario: settings screen opens from toggle action
Given a mock command router and persona state
And a temporary settings file path for the app
When I instantiate the Textual TUI app with settings path
And I call on_mount on the app
And I call action_toggle_settings on the app
Then the settings overlay should contain "Settings"
And the settings overlay should contain "Search:"
Scenario: settings schema includes 30 plus settings across six groups
Given a mock command router and persona state
And a temporary settings file path for the app
When I instantiate the Textual TUI app with settings path
Then the settings schema should include at least 30 settings
And the settings schema should include 6 groups
Scenario: settings search filters values in real time
Given a mock command router and persona state
And a temporary settings file path for the app
When I instantiate the Textual TUI app with settings path
And I call on_mount on the app
And I call action_toggle_settings on the app
And I submit "flash" to the app
Then the settings overlay should contain "Flash Duration"
And the settings overlay should not contain "Shell Command"
Scenario: setting changes persist to tui-settings.json
Given a mock command router and persona state
And a temporary settings file path for the app
When I instantiate the Textual TUI app with settings path
And I call on_mount on the app
And I call action_toggle_settings on the app
And I call action_cycle_setting_value on the app
And I call action_toggle_settings on the app
Then the settings file should exist
And the persisted settings should include key "ui.theme"
Scenario: setting changes apply immediately
Given a mock command router and persona state
And a temporary settings file path for the app
When I instantiate the Textual TUI app with settings path
And I call on_mount on the app
And I call action_toggle_settings on the app
And I call action_cycle_setting_value on the app
Then the conversation widget should contain "Applied setting:"
+134 -2
View File
@@ -5,11 +5,17 @@ from __future__ import annotations
import importlib
import os
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
from cleveragents.tui.input.modes import InputMode, InputModeRouter
from cleveragents.tui.input.reference_parser import suggestions
from cleveragents.tui.persona.state import PersonaState
from cleveragents.tui.settings import (
SettingDefinition,
SettingsStore,
SettingValue,
)
from cleveragents.tui.slash_catalog import slash_command_names
from cleveragents.tui.widgets.help_panel_overlay import (
HelpPanelOverlay,
@@ -18,6 +24,7 @@ from cleveragents.tui.widgets.help_panel_overlay import (
from cleveragents.tui.widgets.persona_bar import PersonaBar
from cleveragents.tui.widgets.prompt import PromptInput
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
from cleveragents.tui.widgets.settings_overlay import SettingsOverlay
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
if TYPE_CHECKING:
@@ -70,9 +77,13 @@ class _FallbackCleverAgentsTuiApp: # pragma: no cover
"""Fallback app that raises actionable dependency error."""
def __init__(
self, *, command_router: _CommandRouter, persona_state: PersonaState
self,
*,
command_router: _CommandRouter,
persona_state: PersonaState,
settings_path: Path | None = None,
) -> None:
del command_router, persona_state
del command_router, persona_state, settings_path
def run(self) -> None:
raise RuntimeError(
1
@@ -91,6 +102,11 @@ if _TEXTUAL_AVAILABLE:
("ctrl+q", "quit", "Quit"),
("f1", "help", "Help"),
("ctrl+t", "cycle_preset", "Cycle Preset"),
("f2", "toggle_settings", "Settings"),
("ctrl+,", "toggle_settings", "Settings"),
("j", "settings_next", "Next Setting"),
("k", "settings_previous", "Previous Setting"),
("enter", "cycle_setting_value", "Cycle Value"),
]
def __init__(
1
@@ -98,17 +114,24 @@ if _TEXTUAL_AVAILABLE:
*,
command_router: _CommandRouter,
persona_state: PersonaState,
settings_path: Path | None = None,
) -> None:
Review

Merge conflict area: Master replaced this with HelpPanelOverlay.toggle() context-sensitive help. After rebase, keep the new help panel behavior and update the help text to also mention F2/settings if desired.

**Merge conflict area:** Master replaced this with `HelpPanelOverlay.toggle()` context-sensitive help. After rebase, keep the new help panel behavior and update the help text to also mention F2/settings if desired.
super().__init__()
self._command_router = command_router
self._persona_state = persona_state
self._session = SessionView(session_id="default", transcript=[])
self._settings_store = SettingsStore(path=settings_path)
self._settings_values = self._settings_store.load()
self._settings_visible = False
self._settings_search_query = ""
self._settings_selected_index = 0
def compose(self) -> Any:
yield _Header(show_clock=True)
with _Vertical(id="main-column"):
yield _Static("CleverAgents TUI", id="conversation")
yield HelpPanelOverlay(id="help-panel")
yield SettingsOverlay(id="settings-overlay")
yield ReferencePickerOverlay(id="reference-picker")
yield SlashCommandOverlay(id="slash-overlay")
yield PromptInput(
@@ -125,6 +148,7 @@ if _TEXTUAL_AVAILABLE:
ref_picker.set_suggestions("", [])
slash = self.query_one("#slash-overlay", SlashCommandOverlay)
slash.set_commands("", slash_command_names())
self._render_settings_overlay()
def action_help(self) -> None:
prompt = self.query_one("#prompt", PromptInput)
@@ -137,6 +161,10 @@ if _TEXTUAL_AVAILABLE:
self._refresh_persona_bar()
def _refresh_persona_bar(self) -> None:
if not bool(self._settings_values.get("ui.info_bar", True)):
bar = self.query_one("#persona-bar", PersonaBar)
bar.update("")
return
persona = self._persona_state.active_persona(self._session.session_id)
preset = self._persona_state.current_preset(self._session.session_id)
scope_count = len(persona.scoped_projects) + len(persona.scoped_plans)
@@ -149,6 +177,104 @@ if _TEXTUAL_AVAILABLE:
scope_text=scope_text,
)
def _filtered_settings(self) -> list[SettingDefinition]:
return self._settings_store.filter_definitions(self._settings_search_query)
def _grouped_setting_lines(
self, definitions: list[SettingDefinition]
) -> list[tuple[str, list[str]]]:
grouped: dict[str, list[str]] = {}
selected_key = None
if definitions:
selected_key = definitions[self._settings_selected_index].key
for definition in definitions:
marker = "*" if definition.key == selected_key else " "
value = self._settings_values.get(definition.key, definition.default)
line = f"{marker} {definition.label} [{value}] ({definition.key})"
grouped.setdefault(definition.group, []).append(line)
ordered: list[tuple[str, list[str]]] = []
for name in self._settings_store.group_names():
if name in grouped:
ordered.append((name, grouped[name]))
return ordered
def _render_settings_overlay(self) -> None:
definitions = self._filtered_settings()
if definitions:
self._settings_selected_index = min(
self._settings_selected_index,
len(definitions) - 1,
)
else:
self._settings_selected_index = 0
grouped_lines = self._grouped_setting_lines(definitions)
overlay = self.query_one("#settings-overlay", SettingsOverlay)
overlay.set_content(
visible=self._settings_visible,
search_query=self._settings_search_query,
grouped_lines=grouped_lines,
total_count=len(definitions),
)
def set_settings_search_query(self, query: str) -> None:
self._settings_search_query = query
self._settings_selected_index = 0
self._render_settings_overlay()
def _persist_settings(self) -> None:
self._settings_store.save(self._settings_values)
def _update_setting(self, key: str, value: SettingValue) -> None:
self._settings_values[key] = value
self._persist_settings()
self._render_settings_overlay()
self._refresh_persona_bar()
conversation = self.query_one("#conversation", _Static)
conversation.update(f"Applied setting: {key}={value}")
def action_toggle_settings(self) -> None:
self._settings_visible = not self._settings_visible
Outdated
Review

Bug: This method (and action_settings_previous and action_cycle_setting_value below) does not check self._settings_visible before modifying state. Since j is a global App binding, this will silently change _settings_selected_index even when the settings screen is closed. More critically, action_cycle_setting_value will modify and persist settings without the user seeing the settings screen.

Add if not self._settings_visible: return as the first line of all three action methods.

**Bug:** This method (and `action_settings_previous` and `action_cycle_setting_value` below) does not check `self._settings_visible` before modifying state. Since `j` is a global App binding, this will silently change `_settings_selected_index` even when the settings screen is closed. More critically, `action_cycle_setting_value` will modify and persist settings without the user seeing the settings screen. Add `if not self._settings_visible: return` as the first line of all three action methods.
if not self._settings_visible:
self._persist_settings()
self._render_settings_overlay()
def action_settings_next(self) -> None:
Outdated
Review

Bug (unfixed from prior review): action_settings_next, action_settings_previous, and action_cycle_setting_value do not check self._settings_visible before executing. Since j, k, and enter are global App bindings, these actions can fire when the settings screen is closed.

action_cycle_setting_value is the most dangerous: it will silently modify a setting, persist it to disk, show "Applied setting: ..." in the conversation widget, and refresh the persona bar — all without the user intending to change any setting.

Fix: Add if not self._settings_visible: return as the first line of action_settings_next (line 242), action_settings_previous (line 251), and action_cycle_setting_value (line 260).

**Bug (unfixed from prior review):** `action_settings_next`, `action_settings_previous`, and `action_cycle_setting_value` do not check `self._settings_visible` before executing. Since `j`, `k`, and `enter` are global App bindings, these actions can fire when the settings screen is closed. `action_cycle_setting_value` is the most dangerous: it will silently modify a setting, persist it to disk, show "Applied setting: ..." in the conversation widget, and refresh the persona bar — all without the user intending to change any setting. **Fix:** Add `if not self._settings_visible: return` as the first line of `action_settings_next` (line 242), `action_settings_previous` (line 251), and `action_cycle_setting_value` (line 260).
if not self._settings_visible:
return
definitions = self._filtered_settings()
if not definitions:
return
self._settings_selected_index = (self._settings_selected_index + 1) % len(
definitions
)
self._render_settings_overlay()
def action_settings_previous(self) -> None:
if not self._settings_visible:
return
definitions = self._filtered_settings()
if not definitions:
return
self._settings_selected_index = (self._settings_selected_index - 1) % len(
definitions
)
self._render_settings_overlay()
def action_cycle_setting_value(self) -> None:
if not self._settings_visible:
return
definitions = self._filtered_settings()
if not definitions:
return
definition = definitions[self._settings_selected_index]
current = self._settings_values.get(definition.key, definition.default)
next_value = self._settings_store.cycle_value(
key=definition.key,
current=current,
)
self._update_setting(definition.key, next_value)
def on_input_submitted(self, event: InputSubmittedEvent) -> None:
del event
prompt = self.query_one("#prompt", PromptInput)
@@ -157,6 +283,12 @@ if _TEXTUAL_AVAILABLE:
if not text:
return
if self._settings_visible:
self.set_settings_search_query(text)
conversation = self.query_one("#conversation", _Static)
conversation.update(f"Settings search: {text}")
return
mode_router = InputModeRouter(
command_handler=lambda raw: self._command_router.handle(
raw, session_id=self._session.session_id
+8
View File
@@ -22,6 +22,14 @@ Screen {
background: $panel-lighten-1;
}
#settings-overlay {
height: auto;
max-height: 18;
padding: 0 1;
border: round $success;
color: $text;
}
#reference-picker {
height: auto;
max-height: 8;
+440
View File
@@ -0,0 +1,440 @@
"""Schema-driven TUI settings state and persistence."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
SettingValue = bool | int | float | str
@dataclass(frozen=True, slots=True)
class SettingDefinition:
"""Declarative schema entry for one TUI setting."""
key: str
group: str
label: str
kind: str
default: SettingValue
description: str
choices: tuple[str, ...] = ()
minimum: float | None = None
maximum: float | None = None
SETTINGS_SCHEMA: tuple[SettingDefinition, ...] = (
SettingDefinition(
key="ui.theme",
group="UI",
label="Theme",
kind="choice",
default="dracula",
choices=("dracula", "monokai", "nord", "solarized-dark", "gruvbox"),
description="Color theme for the entire TUI",
),
SettingDefinition(
key="ui.column",
group="UI",
label="Column Layout",
kind="bool",
default=True,
description="Enable fixed column width for conversation content",
),
SettingDefinition(
key="ui.column_width",
group="UI",
label="Column Width",
kind="int",
default=100,
minimum=40,
maximum=300,
description="Maximum column width when column layout is enabled",
),
SettingDefinition(
key="ui.scrollbar",
group="UI",
label="Scrollbar",
kind="choice",
default="normal",
choices=("normal", "thin", "hidden"),
description="Scrollbar appearance across the TUI",
),
SettingDefinition(
key="ui.compact_input",
group="UI",
label="Compact Input",
kind="bool",
default=False,
description="Use compact prompt styling",
),
SettingDefinition(
key="ui.sessions_bar",
group="UI",
label="Sessions Bar",
kind="choice",
default="multiple",
choices=("always", "multiple", "never"),
description="When to show the session tab bar",
),
SettingDefinition(
key="ui.footer",
group="UI",
label="Footer",
kind="bool",
default=True,
description="Show hotkey footer",
),
SettingDefinition(
key="ui.info_bar",
group="UI",
label="Info Bar",
kind="bool",
default=True,
description="Show persona and status info bar",
),
SettingDefinition(
key="ui.status_line",
group="UI",
label="Status Line",
kind="bool",
default=True,
description="Show token/cost status line",
),
SettingDefinition(
key="ui.flash_duration",
group="UI",
label="Flash Duration",
kind="float",
default=5.0,
minimum=0.5,
maximum=30.0,
description="Seconds before flash messages auto-dismiss",
),
SettingDefinition(
key="ui.auto_copy",
group="UI",
label="Auto Copy",
kind="bool",
default=True,
description="Auto-copy selected text",
),
SettingDefinition(
key="ui.prune_low_mark",
group="UI",
label="Prune Low Mark",
kind="int",
default=1500,
minimum=100,
maximum=10000,
description="Target line count after pruning",
),
SettingDefinition(
key="ui.prune_excess",
group="UI",
label="Prune Excess",
kind="int",
default=1000,
minimum=100,
maximum=5000,
description="Excess lines that trigger pruning",
),
SettingDefinition(
key="ui.throbber",
group="UI",
label="Throbber",
kind="choice",
default="rainbow",
choices=("rainbow", "quotes"),
description="Loading indicator style",
),
SettingDefinition(
key="ui.font_size",
group="UI",
label="Font Size",
kind="int",
default=12,
minimum=8,
maximum=24,
description="Preferred monospace font size hint",
),
SettingDefinition(
key="notifications.desktop",
group="Notifications",
label="Desktop Notifications",
kind="choice",
default="on-blur",
choices=("never", "on-blur", "always"),
description="When to send desktop notifications",
),
SettingDefinition(
key="notifications.blink_title",
group="Notifications",
label="Blink Terminal Title",
kind="bool",
default=True,
description="Blink terminal title when input is needed",
),
SettingDefinition(
key="notifications.enable_sounds",
group="Notifications",
label="Enable Sounds",
kind="bool",
default=True,
description="Play sound effects on events",
),
SettingDefinition(
key="notifications.turn_complete",
group="Notifications",
label="Turn Complete Alerts",
kind="bool",
default=True,
description="Notify when actor turn completes",
),
SettingDefinition(
key="notifications.hide_low_severity",
group="Notifications",
label="Hide Low Severity",
kind="bool",
default=True,
description="Suppress low-severity notifications",
),
SettingDefinition(
key="sidebar.auto_hide",
group="Sidebar",
label="Auto Hide",
kind="bool",
default=False,
description="Auto-hide sidebar when unfocused",
),
SettingDefinition(
key="sidebar.width",
group="Sidebar",
label="Sidebar Width",
kind="int",
default=36,
minimum=24,
maximum=60,
description="Sidebar width in characters",
),
SettingDefinition(
key="agent.thoughts",
group="Agent",
label="Show Thoughts",
kind="bool",
default=True,
description="Render thought/reasoning blocks",
),
SettingDefinition(
key="tools.expand",
group="Tools",
label="Auto Expand Tools",
kind="choice",
default="fail",
choices=("never", "always", "success", "fail", "both"),
description="When tool results auto-expand",
),
SettingDefinition(
key="diff.view",
group="Tools",
label="Diff View",
kind="choice",
default="auto",
choices=("unified", "split", "auto"),
description="Diff presentation mode",
),
SettingDefinition(
key="shell.command",
group="Shell",
label="Shell Command",
kind="text",
default="/bin/sh",
description="Shell executable for ! mode",
),
SettingDefinition(
key="shell.startup_commands",
group="Shell",
label="Startup Commands",
kind="text",
default='PS1=""',
description="Commands executed at shell startup",
),
SettingDefinition(
key="shell.warn_dangerous",
group="Shell",
label="Warn Dangerous",
kind="bool",
default=True,
description="Warn for dangerous shell commands",
),
SettingDefinition(
key="shell.allow_commands",
group="Shell",
label="Allowed Commands",
kind="text",
default="git,ls,cat",
description="Commands recognized as shell mode",
),
SettingDefinition(
key="shell.directory_commands",
group="Shell",
label="Directory Commands",
kind="text",
default="cd,rmdir",
description="Commands with directory completion",
),
SettingDefinition(
key="shell.file_commands",
group="Shell",
label="File Commands",
kind="text",
default="cat,less,head",
description="Commands with file completion",
),
)
DEFAULT_SETTINGS = {
definition.key: definition.default for definition in SETTINGS_SCHEMA
}
def default_settings_path() -> Path:
"""Return the default settings file path."""
return Path.home() / ".config" / "cleveragents" / "tui-settings.json"
class SettingsStore:
"""Persistence and schema utility for TUI settings."""
def __init__(self, path: Path | None = None) -> None:
self._path = path or default_settings_path()
self._definitions = SETTINGS_SCHEMA
self._by_key = {definition.key: definition for definition in self._definitions}
@property
def path(self) -> Path:
"""Path to the persisted JSON file."""
return self._path
def definitions(self) -> list[SettingDefinition]:
"""Return schema definitions in deterministic order."""
return list(self._definitions)
def group_names(self) -> list[str]:
"""Return unique group names in schema order."""
names: list[str] = []
for definition in self._definitions:
if definition.group not in names:
names.append(definition.group)
return names
def load(self) -> dict[str, SettingValue]:
"""Load persisted settings merged with schema defaults."""
values: dict[str, SettingValue] = dict(DEFAULT_SETTINGS)
if not self._path.exists():
return values
try:
with self._path.open(encoding="utf-8") as stream:
payload = json.load(stream)
except (OSError, json.JSONDecodeError):
return values
if not isinstance(payload, dict):
return values
for key, raw in payload.items():
definition = self._by_key.get(key)
if definition is None:
continue
values[key] = self._coerce(definition, raw)
return values
def save(self, values: dict[str, SettingValue]) -> None:
"""Persist settings to JSON."""
self._path.parent.mkdir(parents=True, exist_ok=True)
filtered = {
key: values[key]
for key in DEFAULT_SETTINGS
if key in values and values[key] != DEFAULT_SETTINGS[key]
}
with self._path.open("w", encoding="utf-8") as stream:
json.dump(filtered, stream, indent=2, sort_keys=True)
def filter_definitions(self, query: str) -> list[SettingDefinition]:
"""Filter schema entries by key/label/description."""
needle = query.strip().lower()
if not needle:
return self.definitions()
matches: list[SettingDefinition] = []
for definition in self._definitions:
haystack = (
f"{definition.key} {definition.label} {definition.description}"
).lower()
if needle in haystack:
matches.append(definition)
return matches
def cycle_value(self, *, key: str, current: SettingValue) -> SettingValue:
"""Return the next value for interactive setting edits."""
definition = self._by_key[key]
if definition.kind == "bool":
return not bool(current)
if definition.kind == "choice":
values = definition.choices
if not values:
return current
current_value = str(current)
if current_value not in values:
return values[0]
index = values.index(current_value)
return values[(index + 1) % len(values)]
if definition.kind == "int":
value = int(current)
maximum = (
int(definition.maximum) if definition.maximum is not None else value
)
minimum = (
int(definition.minimum) if definition.minimum is not None else value
)
next_value = value + 1
if next_value > maximum:
return minimum
return next_value
if definition.kind == "float":
value = float(current)
maximum = (
float(definition.maximum) if definition.maximum is not None else value
)
minimum = (
float(definition.minimum) if definition.minimum is not None else value
)
next_value = round(value + 0.5, 1)
if next_value > maximum:
return minimum
return next_value
return str(current)
@staticmethod
def _coerce(definition: SettingDefinition, raw: object) -> SettingValue:
if definition.kind == "bool":
return bool(raw)
if definition.kind == "int":
if isinstance(raw, bool):
return int(definition.default)
if isinstance(raw, int | float):
return int(raw)
return int(definition.default)
if definition.kind == "float":
if isinstance(raw, bool):
return float(definition.default)
if isinstance(raw, int | float):
return float(raw)
return float(definition.default)
if definition.kind == "choice":
candidate = str(raw)
if candidate in definition.choices:
return candidate
return str(definition.default)
return str(raw)
+2
View File
@@ -4,6 +4,7 @@ from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay
from cleveragents.tui.widgets.persona_bar import PersonaBar
from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
from cleveragents.tui.widgets.settings_overlay import SettingsOverlay
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
__all__ = [
@@ -12,5 +13,6 @@ __all__ = [
"PromptInput",
"PromptSubmitted",
"ReferencePickerOverlay",
"SettingsOverlay",
"SlashCommandOverlay",
]
@@ -0,0 +1,50 @@
"""Settings screen overlay widget."""
from __future__ import annotations
import importlib
from typing import Any
def _load_static_base() -> type[Any]:
try:
return importlib.import_module("textual.widgets").Static
except Exception: # pragma: no cover
class _FallbackStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
return _FallbackStatic
_StaticBase = _load_static_base()
class SettingsOverlay(_StaticBase):
"""Renderable settings screen with grouped schema entries."""
def set_content(
self,
*,
visible: bool,
search_query: str,
grouped_lines: list[tuple[str, list[str]]],
total_count: int,
) -> None:
if not visible:
self.update("")
return
lines = ["Settings", "", f"Search: {search_query}", ""]
for group_name, group_lines in grouped_lines:
lines.append(group_name)
lines.extend(f" {line}" for line in group_lines)
lines.append("")
lines.append(f"Matched settings: {total_count}")
lines.append("enter Cycle | type to search | esc/F2/ctrl+, close")
self.update("\n".join(lines))