feat(tui): implement SettingsScreen with search-driven navigation and persistence #1237
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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(
|
||||
@@ -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__(
|
||||
@@ -98,17 +114,24 @@ if _TEXTUAL_AVAILABLE:
|
||||
*,
|
||||
command_router: _CommandRouter,
|
||||
persona_state: PersonaState,
|
||||
settings_path: Path | None = None,
|
||||
) -> None:
|
||||
|
|
||||
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
|
||||
|
freemo
commented
Bug: This method (and Add **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:
|
||||
|
freemo
commented
Bug (unfixed from prior review):
Fix: Add **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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
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.