This commit is contained in:
2026-01-26 02:29:56 -08:00
parent 0c336ef1d1
commit 334841dcfa
5 changed files with 510 additions and 65 deletions

2
CLI.py
View File

@@ -1207,6 +1207,8 @@ class CmdletExecutor:
"get_url",
"search-file",
"search_file",
"add-file",
"add_file",
}
if cmd_name in self_managing_commands:

View File

@@ -249,12 +249,6 @@ class Matrix(TableProviderMixin, Provider):
"default": "",
"secret": True
},
{
"key": "password",
"label": "Password (fallback)",
"default": "",
"secret": True
}
]
def __init__(self, config: Optional[Dict[str, Any]] = None):
@@ -270,12 +264,10 @@ class Matrix(TableProviderMixin, Provider):
)
homeserver = matrix_conf.get("homeserver")
access_token = matrix_conf.get("access_token")
password = matrix_conf.get("password")
# Not configured: keep instance but mark invalid via validate().
# Note: `room_id` is intentionally NOT required, since the CLI can prompt
# the user to select a room dynamically.
if not (homeserver and (access_token or password)):
if not (homeserver and access_token):
self._init_ok = None
self._init_reason = None
return
@@ -305,7 +297,7 @@ class Matrix(TableProviderMixin, Provider):
{})
return bool(
matrix_conf.get("homeserver")
and (matrix_conf.get("access_token") or matrix_conf.get("password"))
and matrix_conf.get("access_token")
)
def search(

View File

@@ -1,14 +1,17 @@
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select
import re
from typing import Any, Dict, List, Optional
from textual import on, work
from typing import Any
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select
from SYS.config import load_config, save_config, reload_config, global_config
from SYS.logger import log
from Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import list_providers
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
from TUI.modalscreen.selection_modal import SelectionModal
class ConfigModal(ModalScreen):
@@ -117,6 +120,8 @@ class ConfigModal(ModalScreen):
self.editing_item_name = None
self._button_id_map = {}
self._input_id_map = {}
self._matrix_status: Optional[Static] = None
self._matrix_test_running = False
def compose(self) -> ComposeResult:
with Container(id="config-container"):
@@ -491,13 +496,28 @@ class ConfigModal(ModalScreen):
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = rk
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
container.mount(row)
idx += 1
except Exception:
pass
if (
item_type == "provider"
and isinstance(item_name, str)
and item_name.strip().lower() == "matrix"
):
container.mount(Rule())
container.mount(Label("Matrix helpers", classes="config-label"))
status = Static("Set homeserver + token, then test before saving", id="matrix-status")
container.mount(status)
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Button("Test connection", id="matrix-test-btn"))
row.mount(Button("Choose default rooms", variant="primary", id="matrix-rooms-btn"))
self._matrix_status = status
def create_field(self, name: str, value: Any, id: str) -> Vertical:
# This method is now unused - we mount labels and inputs directly
v = Vertical(classes="config-field")
@@ -594,6 +614,10 @@ class ConfigModal(ModalScreen):
except Exception:
pass
self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected)
elif bid == "matrix-test-btn":
self._request_matrix_test()
elif bid == "matrix-rooms-btn":
self._open_matrix_room_picker()
elif bid.startswith("paste-"):
# Programmatic paste button
target_id = bid.replace("paste-", "")
@@ -777,6 +801,94 @@ class ConfigModal(ModalScreen):
self._update_config_value(widget_id, value)
def _get_matrix_provider_block(self) -> Dict[str, Any]:
providers = self.config_data.get("provider")
if not isinstance(providers, dict):
return {}
block = providers.get("matrix")
return block if isinstance(block, dict) else {}
def _parse_matrix_rooms_value(self) -> List[str]:
block = self._get_matrix_provider_block()
raw = block.get("rooms")
if isinstance(raw, (list, tuple, set)):
return [str(item).strip() for item in raw if str(item).strip()]
text = str(raw or "").strip()
if not text:
return []
return [segment for segment in re.split(r"[\s,]+", text) if segment]
def _request_matrix_test(self) -> None:
if self._matrix_test_running:
return
self._synchronize_inputs_to_config()
if self._matrix_status:
self._matrix_status.update("Testing Matrix connection…")
self._matrix_test_running = True
self._matrix_test_background()
@work(thread=True)
def _matrix_test_background(self) -> None:
try:
from Provider.matrix import Matrix
provider = Matrix(self.config_data)
rooms = provider.list_rooms()
self.app.call_from_thread(self._matrix_test_result, True, rooms, None)
except Exception as exc:
self.app.call_from_thread(self._matrix_test_result, False, [], str(exc))
def _matrix_test_result(
self,
success: bool,
rooms: List[Dict[str, Any]],
error: Optional[str],
) -> None:
self._matrix_test_running = False
if success:
msg = f"Matrix test succeeded ({len(rooms)} room(s))."
if self._matrix_status:
self._matrix_status.update(msg)
self._open_matrix_room_picker(prefetched_rooms=rooms)
else:
if self._matrix_status:
self._matrix_status.update(f"Matrix test failed: {error}")
def _open_matrix_room_picker(
self,
*,
prefetched_rooms: Optional[List[Dict[str, Any]]] = None,
) -> None:
existing = self._parse_matrix_rooms_value()
self.app.push_screen(
MatrixRoomPicker(
self.config_data,
existing=existing,
rooms=prefetched_rooms,
),
callback=self.on_matrix_rooms_selected,
)
def on_matrix_rooms_selected(self, result: Any = None) -> None:
if not isinstance(result, list):
if self._matrix_status:
self._matrix_status.update("Room selection cancelled.")
return
cleaned: List[str] = []
for item in result:
candidate = str(item or "").strip()
if candidate and candidate not in cleaned:
cleaned.append(candidate)
if not cleaned:
if self._matrix_status:
self._matrix_status.update("No default rooms were saved.")
return
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["rooms"] = ", ".join(cleaned)
if self._matrix_status:
self._matrix_status.update(f"Saved {len(cleaned)} default room(s).")
self.refresh_view()
@on(Input.Changed)
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id:

View File

@@ -0,0 +1,197 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.screen import ModalScreen
from textual.widgets import Static, Button, Checkbox
from textual import work
class MatrixRoomPicker(ModalScreen[List[str]]):
"""Modal that lists Matrix rooms and returns selected defaults."""
CSS = """
MatrixRoomPicker {
align: center middle;
background: rgba(0, 0, 0, 0.45);
}
#matrix-room-picker {
width: 70%;
height: 70%;
background: $panel;
border: thick $primary;
padding: 1;
}
#matrix-room-picker-hint {
text-style: italic;
color: $text-muted;
margin-bottom: 1;
}
#matrix-room-scroll {
height: 1fr;
border: solid $surface;
padding: 1;
margin-bottom: 1;
}
#matrix-room-checklist {
padding: 0;
}
.matrix-room-row {
border-bottom: solid $surface;
padding: 1 0;
align: left middle;
background: $surface;
}
.matrix-room-checkbox {
width: 3;
padding: 0;
margin-right: 1;
}
.matrix-room-meta {
padding: 0 1;
}
.matrix-room-name {
content-align: left middle;
color: $text;
}
.matrix-room-id {
content-align: left middle;
text-style: dim;
color: $text-muted;
}
#matrix-room-actions {
height: 3;
align: right middle;
}
#matrix-room-status {
height: 3;
text-style: bold;
}
"""
def __init__(
self,
config: Dict[str, Any],
*,
existing: Optional[List[str]] = None,
rooms: Optional[List[Dict[str, Any]]] = None,
) -> None:
super().__init__()
self.config = config
self._prefetched_rooms = rooms
self._existing_ids = {str(r).strip() for r in (existing or []) if str(r).strip()}
self._checkbox_map: Dict[str, str] = {}
self._rooms: List[Dict[str, Any]] = []
self._status_widget: Optional[Static] = None
self._checklist: Optional[Vertical] = None
self._save_button: Optional[Button] = None
def compose(self) -> ComposeResult:
with Container(id="matrix-room-picker"):
yield Static("Matrix Default Rooms", classes="section-title")
yield Static(
"Choose rooms to keep in the sharing defaults and autocomplete.",
id="matrix-room-picker-hint",
)
with ScrollableContainer(id="matrix-room-scroll"):
yield Vertical(id="matrix-room-checklist")
with Horizontal(id="matrix-room-actions"):
yield Button("Cancel", variant="error", id="matrix-room-cancel")
yield Button("Save defaults", variant="success", id="matrix-room-save")
yield Static("Loading rooms...", id="matrix-room-status")
def on_mount(self) -> None:
self._status_widget = self.query_one("#matrix-room-status", Static)
self._checklist = self.query_one("#matrix-room-checklist", Vertical)
self._save_button = self.query_one("#matrix-room-save", Button)
if self._save_button:
self._save_button.disabled = True
if self._prefetched_rooms is not None:
self._apply_room_results(self._prefetched_rooms, None)
else:
if self._status_widget:
self._set_status("Loading rooms...")
self._load_rooms_background()
def _set_status(self, text: str) -> None:
if self._status_widget:
self._status_widget.update(text)
def _render_rooms(self, rooms: List[Dict[str, Any]]) -> None:
if not self._checklist:
return
for child in list(self._checklist.children):
child.remove()
self._checkbox_map.clear()
self._rooms = rooms
if not rooms:
self._set_status("Matrix returned no rooms.")
if self._save_button:
self._save_button.disabled = True
return
for idx, room in enumerate(rooms):
room_id = str(room.get("room_id") or "").strip()
name = str(room.get("name") or "").strip()
checkbox_id = f"matrix-room-{idx}"
checkbox = Checkbox(
"",
id=checkbox_id,
classes="matrix-room-checkbox",
value=bool(room_id and room_id in self._existing_ids),
)
self._checkbox_map[checkbox_id] = room_id
info = Vertical(classes="matrix-room-meta")
info.mount(Static(name or room_id or "Matrix Room", classes="matrix-room-name"))
info.mount(Static(room_id or "(no id)", classes="matrix-room-id"))
row = Horizontal(classes="matrix-room-row")
self._checklist.mount(row)
row.mount(checkbox)
row.mount(info)
self._set_status("Loaded rooms. Select one or more and save.")
if self._save_button:
self._save_button.disabled = False
@work(thread=True)
def _load_rooms_background(self) -> None:
try:
from Provider.matrix import Matrix
provider = Matrix(self.config)
rooms = provider.list_rooms()
self.app.call_from_thread(self._apply_room_results, rooms, None)
except Exception as exc:
self.app.call_from_thread(self._apply_room_results, [], str(exc))
def _apply_room_results(self, rooms: List[Dict[str, Any]], error: Optional[str]) -> None:
if error:
self._set_status(f"Failed to load Matrix rooms: {error}")
if self._save_button:
self._save_button.disabled = True
return
self._render_rooms(rooms)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "matrix-room-cancel":
self.dismiss([])
elif event.button.id == "matrix-room-save":
selected: List[str] = []
for checkbox_id, room_id in self._checkbox_map.items():
try:
cb = self.query_one(f"#{checkbox_id}", Checkbox)
if cb.value and room_id:
selected.append(room_id)
except Exception:
pass
self.dismiss(selected)

View File

@@ -9,6 +9,7 @@ import uuid
from urllib.parse import parse_qs, urlparse
from cmdlet._shared import Cmdlet, CmdletArg
from SYS.config import load_config, save_config
from SYS.logger import log, debug
from SYS.result_table import Table
from SYS import pipeline as ctx
@@ -78,55 +79,43 @@ def _extract_set_value_arg(args: Sequence[str]) -> Optional[str]:
def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
"""Update a Matrix config value and write to config file.
Returns True if successful, False otherwise.
"""Update the Matrix provider block in the shared config.
This method writes to the unified config store so changes persist between
sessions.
"""
try:
from SYS.config import get_config_path
from configparser import ConfigParser
if not isinstance(config, dict):
return False
# Ensure provider.matrix section exists
providers = config.get("provider", {})
value_str = str(value)
current_cfg = load_config() or {}
providers = current_cfg.setdefault("provider", {})
if not isinstance(providers, dict):
providers = {}
config["provider"] = providers
matrix_conf = providers.get("matrix", {})
if not isinstance(matrix_conf, dict):
matrix_conf = {}
providers["matrix"] = matrix_conf
# Update the in-memory config
matrix_conf[key] = value
# Try to write to config file using configparser
try:
config_path = get_config_path()
if not config_path:
return False
parser = ConfigParser()
if Path(config_path).exists():
parser.read(config_path)
section_name = "provider=matrix"
if not parser.has_section(section_name):
parser.add_section(section_name)
parser.set(section_name, key, str(value))
with open(config_path, "w") as f:
parser.write(f)
return True
except Exception as exc:
debug(f"[matrix] Failed to write config file: {exc}")
# Config was updated in memory at least
return True
current_cfg["provider"] = providers
matrix_cfg = providers.setdefault("matrix", {})
if not isinstance(matrix_cfg, dict):
matrix_cfg = {}
providers["matrix"] = matrix_cfg
matrix_cfg[key] = value_str
save_config(current_cfg)
# Keep the supplied config dict in sync for the running CLI
target_providers = config.setdefault("provider", {})
if not isinstance(target_providers, dict):
target_providers = {}
config["provider"] = target_providers
target_matrix = target_providers.setdefault("matrix", {})
if not isinstance(target_matrix, dict):
target_matrix = {}
target_providers["matrix"] = target_matrix
target_matrix[key] = value_str
return True
except Exception as exc:
debug(f"[matrix] Failed to update Matrix config: {exc}")
return False
@@ -628,14 +617,14 @@ def _show_settings_table(config: Dict[str, Any]) -> int:
settings_items = []
if isinstance(matrix_conf, dict):
sensitive_keys = {"access_token", "password"}
for key in sorted(matrix_conf.keys()):
value = matrix_conf[key]
# Skip sensitive/complex values
if key in ("password",):
value = "***"
display_value = "***" if key in sensitive_keys else str(value)
settings_items.append({
"key": key,
"value": str(value),
"label": key,
"value": display_value,
"original_value": value,
})
@@ -643,10 +632,19 @@ def _show_settings_table(config: Dict[str, Any]) -> int:
log("No Matrix settings configured. Edit config.conf manually.", file=sys.stderr)
return 0
settings_items.append({
"action": "test",
"label": "Test connection",
"value": "Verify the homeserver and token before picking rooms",
"description": "Runs a health check and then prompts for default rooms",
})
for item in settings_items:
row = table.add_row()
row.add_column("Key", item["key"])
row.add_column("Value", item["value"])
label = item.get("label") or item.get("key") or "Setting"
value_text = item.get("value") or item.get("description") or ""
row.add_column("Key", label)
row.add_column("Value", value_text)
ctx.set_last_result_table_overlay(table, settings_items)
ctx.set_current_stage_table(table)
@@ -712,6 +710,15 @@ def _handle_settings_edit(result: Any, args: Sequence[str], config: Dict[str, An
return 1
selected_item = last_items[idx]
selected_action = None
if isinstance(selected_item, dict):
selected_action = selected_item.get("action")
else:
selected_action = getattr(selected_item, "action", None)
if selected_action == "test":
return _handle_settings_test(config)
key = None
if isinstance(selected_item, dict):
key = selected_item.get("key")
@@ -742,6 +749,132 @@ def _handle_settings_edit(result: Any, args: Sequence[str], config: Dict[str, An
return 1
def _handle_settings_test(config: Dict[str, Any]) -> int:
"""Test Matrix credentials and prompt for default rooms upon success."""
from Provider.matrix import Matrix
try:
provider = Matrix(config)
except Exception as exc:
log(f"Matrix test failed: {exc}", file=sys.stderr)
return 1
log("Matrix configuration validated. Select default rooms to share.")
return _show_default_room_picker(config, provider=provider)
def _show_default_room_picker(config: Dict[str, Any], *, provider: Optional["Matrix"] = None) -> int:
"""Display joined rooms so the user can select defaults for sharing."""
from Provider.matrix import Matrix
try:
if provider is None:
provider = Matrix(config)
except Exception as exc:
log(f"Matrix not available: {exc}", file=sys.stderr)
return 1
try:
rooms = provider.list_rooms()
except Exception as exc:
log(f"Failed to list Matrix rooms: {exc}", file=sys.stderr)
return 1
if not rooms:
log("No joined rooms found.", file=sys.stderr)
return 0
default_ids = {
str(v).strip()
for v in _parse_config_room_filter_ids(config)
if str(v).strip()
}
table = Table("Matrix Rooms (select defaults with @N)")
table.set_table("matrix")
table.set_source_command(".matrix", ["-settings"])
room_items: List[Dict[str, Any]] = []
for room in rooms:
if isinstance(room, dict):
room_id = str(room.get("room_id") or "").strip()
name = str(room.get("name") or "").strip()
else:
room_id = ""
name = ""
row = table.add_row()
row.add_column("Name", name)
row.add_column("Room", room_id)
row.add_column("Default", "" if room_id and room_id in default_ids else "")
room_items.append({
**(room if isinstance(room, dict) else {}),
"room_id": room_id,
"name": name,
"store": "matrix",
"title": name or room_id or "Matrix Room",
})
ctx.set_last_result_table_overlay(table, room_items)
ctx.set_current_stage_table(table)
ctx.set_pending_pipeline_tail([[".matrix", "-settings-rooms"]], ".matrix")
log("Select default rooms to share (used by @N and autocomplete).")
return 0
def _handle_settings_rooms(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Store the selected rooms list as the default sharing target."""
selection_indices = []
try:
selection_indices = ctx.get_last_selection() or []
except Exception:
pass
last_items = []
try:
last_items = ctx.get_last_result_items() or []
except Exception:
pass
if not selection_indices or not last_items:
log("No Matrix room selected. Use @N on the rooms table.", file=sys.stderr)
return 1
room_ids: List[str] = []
for idx in selection_indices:
if not isinstance(idx, int):
continue
if idx < 0 or idx >= len(last_items):
continue
item = last_items[idx]
candidate = None
if isinstance(item, dict):
candidate = item.get("room_id") or item.get("id")
else:
candidate = getattr(item, "room_id", None) or getattr(item, "id", None)
if candidate:
room_ids.append(str(candidate).strip())
cleaned: List[str] = []
for rid in room_ids:
clean = str(rid or "").strip()
if clean and clean not in cleaned:
cleaned.append(clean)
if not cleaned:
log("No valid Matrix room selected.", file=sys.stderr)
return 1
value = ", ".join(cleaned)
if not _update_matrix_config(config, "rooms", value):
log("✗ Failed to save default rooms", file=sys.stderr)
return 1
log(f"✓ Default rooms saved: {value}")
return _show_settings_table(config)
def _show_rooms_table(config: Dict[str, Any]) -> int:
"""Display rooms (refactored original behavior)."""
from Provider.matrix import Matrix
@@ -855,6 +988,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if _has_flag(args, "-settings-edit"):
return _handle_settings_edit(result, args, config)
if _has_flag(args, "-settings-rooms"):
return _handle_settings_rooms(result, args, config)
# Internal stage: send previously selected items to selected rooms.
if _has_flag(args, "-send"):
# Ensure we don't re-print the rooms picker table on the send stage.
@@ -1031,6 +1167,12 @@ CMDLET = Cmdlet(
description="(internal) Handle settings modification",
required=False,
),
CmdletArg(
name="settings-rooms",
type="bool",
description="(internal) Save selected default rooms",
required=False,
),
CmdletArg(
name="set-value",
type="string",