seedcode-cli 6.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,152 @@
1
+ """/doctor — diagnose configuration, network, and provider health.
2
+
3
+ Every check is best-effort and individually guarded: the doctor itself can
4
+ never crash the app, and it never prints tracebacks — just a table of
5
+ pass/warn/fail rows with actionable hints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from rich.table import Table
13
+
14
+ from ..core.providers import ProviderError, get_provider
15
+ from ..utils.helpers import app_dir, config_path, history_dir
16
+ from . import CommandContext, CommandResult, command
17
+
18
+ _OK = "ok"
19
+ _WARN = "warn"
20
+ _FAIL = "fail"
21
+
22
+ _STYLE = {_OK: "seed.success", _WARN: "seed.warning", _FAIL: "seed.error"}
23
+ _MARK = {_OK: "PASS", _WARN: "WARN", _FAIL: "FAIL"}
24
+
25
+
26
+ def _check_config_file() -> tuple[str, str]:
27
+ path = config_path()
28
+ if not path.exists():
29
+ return _WARN, "no config file yet (first run) — it is created on save"
30
+ try:
31
+ json.loads(path.read_text(encoding="utf-8"))
32
+ return _OK, str(path)
33
+ except (OSError, ValueError) as exc:
34
+ return _FAIL, f"config.json unreadable ({exc}) — defaults are used"
35
+
36
+
37
+ def _check_storage() -> tuple[str, str]:
38
+ probe = history_dir() / ".doctor-probe"
39
+ try:
40
+ probe.write_text("ok", encoding="utf-8")
41
+ probe.unlink()
42
+ return _OK, str(app_dir())
43
+ except OSError as exc:
44
+ return _FAIL, f"cannot write to {app_dir()} ({exc}) — history/config won't persist"
45
+
46
+
47
+ def _check_desktop(config) -> tuple[str, str]:
48
+ """Computer Engine availability (only a WARN when desktop mode is off)."""
49
+ from ..computer import is_available
50
+
51
+ ok, reason = is_available()
52
+ if ok:
53
+ state = "enabled" if config.desktop_mode else "available (off — /desktop on)"
54
+ return _OK, state
55
+ return (_FAIL if config.desktop_mode else _WARN), reason
56
+
57
+
58
+ def _check_ocr() -> tuple[str, str]:
59
+ """OCR engine health — a real probe, not just an import check.
60
+
61
+ Only ever a WARN: OCR is one tier of the detection ladder (accessibility,
62
+ DOM inspection, and URL-driven workflows all come first), so a missing
63
+ engine narrows capability rather than breaking anything.
64
+ """
65
+ try:
66
+ from ..computer import ocr
67
+
68
+ ok, detail = ocr.status()
69
+ except Exception as exc: # the doctor must never crash the app
70
+ return _WARN, f"could not probe OCR ({exc})"
71
+ if not ok:
72
+ return _WARN, detail
73
+ return _OK, ocr.version() or detail
74
+
75
+
76
+ def _run_checks(config) -> list[tuple[str, str, str]]:
77
+ """Return (status, check name, detail) rows."""
78
+ rows: list[tuple[str, str, str]] = []
79
+
80
+ status, detail = _check_config_file()
81
+ rows.append((status, "Config file", detail))
82
+
83
+ status, detail = _check_storage()
84
+ rows.append((status, "Data directory", detail))
85
+
86
+ status, detail = _check_desktop(config)
87
+ rows.append((status, "Desktop control", detail))
88
+
89
+ status, detail = _check_ocr()
90
+ rows.append((status, "OCR engine", detail))
91
+
92
+ try:
93
+ provider = get_provider(config.provider)
94
+ rows.append((_OK, "Active provider", provider.label))
95
+ except ProviderError as exc:
96
+ rows.append((_FAIL, "Active provider", str(exc)))
97
+ return rows # nothing else is checkable without a provider
98
+
99
+ provider.prepare(config) # bind checks to the configured sub-backend
100
+ if provider.requires_key:
101
+ key = config.get_api_key(provider.id).strip()
102
+ if not key:
103
+ rows.append((_FAIL, "API key", "missing — run /apikey"))
104
+ else:
105
+ try:
106
+ result = provider.validate_key(key)
107
+ rows.append(
108
+ (_OK if result.ok else _FAIL, "API key", result.message)
109
+ )
110
+ except Exception: # validation itself must never explode
111
+ rows.append((_WARN, "API key", "could not be validated right now"))
112
+ else:
113
+ rows.append((_OK, "API key", "not required for this provider"))
114
+
115
+ if config.model:
116
+ rows.append((_OK, "Model", config.model))
117
+ else:
118
+ rows.append((_FAIL, "Model", "not selected — run /model"))
119
+
120
+ # Live connectivity: fetching the model list exercises DNS, TLS, and the
121
+ # provider endpoint in one real request.
122
+ try:
123
+ models = provider.list_models(config)
124
+ rows.append((_OK, "Provider reachable", f"{len(models)} models available"))
125
+ except ProviderError as exc:
126
+ rows.append((_FAIL, "Provider reachable", str(exc)))
127
+ except Exception as exc:
128
+ rows.append((_FAIL, "Provider reachable", f"unexpected error: {exc}"))
129
+
130
+ return rows
131
+
132
+
133
+ @command("doctor", "Diagnose configuration, network, and provider health")
134
+ def _doctor(ctx: CommandContext, arg: str) -> CommandResult:
135
+ with ctx.ui.thinking("Running diagnostics"):
136
+ rows = _run_checks(ctx.config)
137
+
138
+ table = Table.grid(padding=(0, 2))
139
+ table.add_column(justify="left", no_wrap=True)
140
+ table.add_column(style="seed.text", no_wrap=True)
141
+ table.add_column(style="seed.dim")
142
+ for status, name, detail in rows:
143
+ table.add_row(
144
+ f"[{_STYLE[status]}]{_MARK[status]}[/{_STYLE[status]}]", name, detail
145
+ )
146
+ ctx.ui.panel(table, title="Doctor")
147
+
148
+ if any(status == _FAIL for status, _, _ in rows):
149
+ ctx.ui.dim("Fix the FAIL rows above; run /doctor again to re-check.")
150
+ else:
151
+ ctx.ui.success("Everything looks healthy.")
152
+ return CommandResult()
@@ -0,0 +1,61 @@
1
+ """Informational commands: /help, /version, /shortcuts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .. import __version__
6
+ from ..ui.layout import shortcuts_grid
7
+ from ..ui.selector import Option, select
8
+ from . import CommandContext, CommandResult, _REGISTRY, command
9
+
10
+ SHORTCUTS: tuple[tuple[str, str], ...] = (
11
+ ("Ctrl+K", "Command Palette"),
12
+ ("Ctrl+P", "Project File Search"),
13
+ ("Ctrl+L", "Clear Screen"),
14
+ ("Ctrl+R", "Search History"),
15
+ ("Ctrl+/", "Keyboard Shortcuts"),
16
+ ("Ctrl+,", "Settings"),
17
+ ("Tab / Shift+Tab", "Next / Previous item"),
18
+ ("↑ ↓", "Move selection"),
19
+ ("Home / End", "Jump to first / last"),
20
+ ("PageUp / PageDown", "Scroll a page"),
21
+ ("Enter", "Confirm"),
22
+ ("Esc", "Back / Cancel"),
23
+ ("Ctrl+C", "Cancel current menu or response"),
24
+ )
25
+
26
+
27
+ def show_shortcuts(ui) -> None:
28
+ """Render the keyboard-shortcut reference panel."""
29
+ ui.panel(shortcuts_grid(SHORTCUTS), title="Keyboard Shortcuts")
30
+
31
+
32
+ @command("help", "Show available commands")
33
+ def _help(ctx: CommandContext, arg: str) -> CommandResult:
34
+ # The command list itself is a searchable selector: Enter shows the
35
+ # command's help line, Esc closes.
36
+ options = [
37
+ Option(f"/{name}", value=name, detail=help_text)
38
+ for name, (_, help_text) in sorted(_REGISTRY.items())
39
+ ]
40
+ chosen = select(
41
+ options,
42
+ title="Commands",
43
+ hint="type to filter ↑↓ move Esc close",
44
+ max_rows=14,
45
+ )
46
+ if chosen is not None:
47
+ _, help_text = _REGISTRY[str(chosen)]
48
+ ctx.ui.info(f"/{chosen} — {help_text}")
49
+ return CommandResult()
50
+
51
+
52
+ @command("shortcuts", "Show keyboard shortcuts", aliases=("keys",))
53
+ def _shortcuts(ctx: CommandContext, arg: str) -> CommandResult:
54
+ show_shortcuts(ctx.ui)
55
+ return CommandResult()
56
+
57
+
58
+ @command("version", "Show the Seed Code version")
59
+ def _version(ctx: CommandContext, arg: str) -> CommandResult:
60
+ ctx.ui.info(f"Seed Code v{__version__}")
61
+ return CommandResult()
@@ -0,0 +1,365 @@
1
+ """Data-inspection and settings commands: /history, /config, /settings.
2
+
3
+ The settings screen is a nested interactive tree with breadcrumbs
4
+ (Settings › Providers › FreeModel Claude); the history browser is a
5
+ searchable selector where Enter opens a transcript and Delete removes it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import ValidationError
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from ..config import save_config
15
+ from ..core.providers import PROVIDERS, ProviderError, get_provider, provider_label
16
+ from ..memory import delete_session, list_sessions, load_session
17
+ from ..tools import PermissionMode
18
+ from ..ui.selector import Option, select
19
+ from ..ui.textbox import read_text
20
+ from ..ui.tree import TreeNode, navigate
21
+ from . import CommandContext, CommandResult, command
22
+
23
+ # Settings editable via /settings, with a tiny parser per value type.
24
+ # (provider/model have their own dedicated commands with live validation.)
25
+ _SETTINGS = {
26
+ "username": str,
27
+ "stream": bool,
28
+ "ollama_host": str,
29
+ "max_tokens": int,
30
+ }
31
+
32
+
33
+ # --- history browser ---------------------------------------------------------
34
+
35
+
36
+ def _show_transcript(ui, provider_id: str, sid: str) -> None:
37
+ """Render one saved session's messages."""
38
+ messages = load_session(provider_id, sid)
39
+ if not messages:
40
+ ui.dim("This session is empty or could not be read.")
41
+ return
42
+ body = Text()
43
+ for msg in messages[:60]:
44
+ role = str(msg.get("role", "?"))
45
+ content = str(msg.get("content", "")).strip()
46
+ if not content:
47
+ continue
48
+ style = "seed.primary" if role == "user" else "seed.accent"
49
+ body.append(f"{role}: ", style=style)
50
+ snippet = content if len(content) <= 500 else content[:500] + " …"
51
+ body.append(snippet + "\n\n", style="seed.text")
52
+ if len(messages) > 60:
53
+ body.append(f"({len(messages) - 60} more messages)", style="seed.dim")
54
+ ui.panel(body, title=f"Session {sid}")
55
+
56
+
57
+ def browse_history(ui, config) -> None:
58
+ """Interactive history: search, Enter opens, Delete removes."""
59
+ provider_id = config.provider
60
+ while True:
61
+ sessions = list_sessions(provider_id)
62
+ if not sessions:
63
+ ui.dim(f"No saved sessions for {provider_label(provider_id)} yet.")
64
+ return
65
+
66
+ def remove(option: Option) -> bool:
67
+ return delete_session(provider_id, str(option.value))
68
+
69
+ chosen = select(
70
+ [
71
+ Option(sid, value=sid, detail=f"{count} messages")
72
+ for sid, count in sessions
73
+ ],
74
+ title=f"History — {provider_label(provider_id)}",
75
+ hint="↑↓ move Enter open Delete remove Esc back",
76
+ on_delete=remove,
77
+ max_rows=14,
78
+ )
79
+ if chosen is None:
80
+ return
81
+ _show_transcript(ui, provider_id, str(chosen))
82
+
83
+
84
+ @command("history", "Browse the active provider's saved sessions")
85
+ def _history(ctx: CommandContext, arg: str) -> CommandResult:
86
+ # History is per provider: only the active backend's sessions are shown.
87
+ browse_history(ctx.ui, ctx.config)
88
+ return CommandResult()
89
+
90
+
91
+ # --- configuration display ---------------------------------------------------
92
+
93
+
94
+ @command("config", "Show current configuration")
95
+ def _config(ctx: CommandContext, arg: str) -> CommandResult:
96
+ table = Table.grid(padding=(0, 3))
97
+ table.add_column(style="seed.dim", justify="right")
98
+ table.add_column(style="seed.text")
99
+ table.add_row("Active provider", provider_label(ctx.config.provider))
100
+ table.add_row("Model", ctx.config.model or "(none — run /model)")
101
+ table.add_row("", "")
102
+ # Every provider keeps its own key + model; switching never loses them.
103
+ for provider in PROVIDERS.values():
104
+ entry = ctx.config.providers.get(provider.id)
105
+ saved_model = entry.model if entry else ""
106
+ if provider.requires_key:
107
+ table.add_row(f"{provider.label} key", ctx.config.masked_key(provider.id))
108
+ table.add_row(f"{provider.label} model", saved_model or "(none)")
109
+ table.add_row("", "")
110
+ table.add_row("Ollama host", ctx.config.ollama_host)
111
+ table.add_row("Theme", ctx.config.theme)
112
+ table.add_row("Username", ctx.config.username)
113
+ table.add_row("Streaming", "on" if ctx.config.stream else "off")
114
+ table.add_row("Max tokens", str(ctx.config.max_tokens))
115
+ ctx.ui.panel(table, title="Configuration")
116
+ return CommandResult()
117
+
118
+
119
+ # --- setting application -----------------------------------------------------
120
+
121
+
122
+ def apply_setting(ui, config, name: str, raw: str) -> None:
123
+ """Parse, validate, apply, and persist one setting change.
124
+
125
+ Global settings first; anything else routes to the ACTIVE provider's own
126
+ settings (e.g. OpenRouter 'mode', Ollama 'host').
127
+ """
128
+ kind = _SETTINGS.get(name)
129
+ if kind is None:
130
+ try:
131
+ provider = get_provider(config.provider)
132
+ except ProviderError:
133
+ provider = None
134
+ if provider is not None and name in provider.extra_settings(config):
135
+ ok, message = provider.set_extra_setting(config, name, raw)
136
+ if ok:
137
+ save_config(config)
138
+ ui.success(message)
139
+ else:
140
+ ui.warning(message)
141
+ return
142
+ known = sorted(_SETTINGS)
143
+ if provider is not None:
144
+ known += sorted(provider.extra_settings(config))
145
+ ui.warning(f"Unknown setting: {name}. Available: {', '.join(known)}")
146
+ return
147
+
148
+ value: object = raw
149
+ if kind is bool:
150
+ lowered = raw.lower()
151
+ if lowered not in ("on", "off", "true", "false"):
152
+ ui.warning(f"'{name}' expects on/off.")
153
+ return
154
+ value = lowered in ("on", "true")
155
+ elif kind is int:
156
+ try:
157
+ value = int(raw)
158
+ except ValueError:
159
+ ui.warning(f"'{name}' expects a number.")
160
+ return
161
+ if value < 1:
162
+ ui.warning(f"'{name}' must be at least 1.")
163
+ return
164
+
165
+ try:
166
+ setattr(config, name, value)
167
+ except ValidationError:
168
+ ui.warning(f"Invalid value for '{name}': {raw}")
169
+ return
170
+ save_config(config)
171
+ ui.success(f"{name} set to {value}")
172
+
173
+
174
+ # --- interactive settings tree ----------------------------------------------
175
+
176
+
177
+ def _edit_text_setting(ui, config, name: str, current: str) -> bool:
178
+ value = read_text(f"{name} > ", default=current)
179
+ if value is None or value == current:
180
+ return True
181
+ apply_setting(ui, config, name, value)
182
+ return True
183
+
184
+
185
+ def _toggle_setting(ui, config, name: str, current: bool) -> bool:
186
+ apply_setting(ui, config, name, "off" if current else "on")
187
+ return True
188
+
189
+
190
+ def _choose_permission(ui, config) -> bool:
191
+ detail = {
192
+ PermissionMode.READ_ONLY: "inspect only — no writes, no commands",
193
+ PermissionMode.WORKSPACE: "edit and run inside this directory only",
194
+ PermissionMode.DESKTOP: "control this computer (mouse, keyboard, apps)",
195
+ PermissionMode.FULL_SYSTEM: "no path restriction + sensitive actions (use with care)",
196
+ }
197
+ chosen = select(
198
+ [Option(mode.label, mode.value_str, detail=detail[mode]) for mode in PermissionMode],
199
+ title="Permission Level",
200
+ initial=config.permission_mode,
201
+ searchable=False,
202
+ hint="↑↓ move Enter select Esc back",
203
+ )
204
+ if chosen is not None:
205
+ config.permission_mode = str(chosen)
206
+ save_config(config)
207
+ ui.success(f"Permission mode set to {PermissionMode.parse(str(chosen)).label}.")
208
+ return True
209
+
210
+
211
+ def _provider_settings_nodes(ui, config, provider) -> list[TreeNode]:
212
+ """One editable node per provider-specific extra setting."""
213
+ nodes: list[TreeNode] = []
214
+ for name in provider.extra_settings(config):
215
+ def edit(n=name, p=provider) -> bool:
216
+ current = p.extra_settings(config).get(n, "")
217
+ value = read_text(f"{n} > ", default=current)
218
+ if value is None or value == current:
219
+ return True
220
+ ok, message = p.set_extra_setting(config, n, value)
221
+ if ok:
222
+ save_config(config)
223
+ ui.success(message)
224
+ else:
225
+ ui.warning(message)
226
+ return True
227
+
228
+ nodes.append(
229
+ TreeNode(
230
+ name,
231
+ action=edit,
232
+ status_fn=lambda n=name, p=provider: p.extra_settings(config).get(n, ""),
233
+ )
234
+ )
235
+ if not nodes:
236
+ nodes.append(TreeNode(f"{provider.label} has no extra settings", action=lambda: True))
237
+ return nodes
238
+
239
+
240
+ def settings_menu(ui, config) -> None:
241
+ """Interactive nested settings with breadcrumbs.
242
+
243
+ Settings › Providers › <provider> reaches each backend's own options;
244
+ Appearance holds the theme picker; History, Models, Keyboard and
245
+ Advanced hold the rest.
246
+ """
247
+ from .theme import pick_theme
248
+
249
+ def providers_nodes() -> list[TreeNode]:
250
+ nodes = []
251
+ for provider in PROVIDERS.values():
252
+ entry = config.providers.get(provider.id)
253
+ model = entry.model if entry and entry.model else "no model"
254
+ nodes.append(
255
+ TreeNode(
256
+ provider.label,
257
+ build=lambda p=provider: _provider_settings_nodes(ui, config, p),
258
+ status=model,
259
+ )
260
+ )
261
+ return nodes
262
+
263
+ def clear_history() -> bool:
264
+ from ..ui.dialog import confirm_dialog
265
+
266
+ sessions = list_sessions(config.provider)
267
+ if not sessions:
268
+ ui.dim("No saved sessions to clear.")
269
+ return True
270
+ if confirm_dialog(
271
+ f"Delete all {len(sessions)} saved sessions for "
272
+ f"{provider_label(config.provider)}?",
273
+ yes_label="Delete All",
274
+ no_label="Keep",
275
+ danger=True,
276
+ ):
277
+ removed = sum(
278
+ 1 for sid, _ in sessions if delete_session(config.provider, sid)
279
+ )
280
+ ui.success(f"Removed {removed} sessions.")
281
+ else:
282
+ ui.dim("History kept.")
283
+ return True
284
+
285
+ def show_shortcuts() -> bool:
286
+ from .help import show_shortcuts as render
287
+
288
+ render(ui)
289
+ return True
290
+
291
+ root = TreeNode(
292
+ "Settings",
293
+ build=lambda: [
294
+ TreeNode("Providers", build=providers_nodes,
295
+ status_fn=lambda: provider_label(config.provider)),
296
+ TreeNode(
297
+ "Appearance",
298
+ build=lambda: [
299
+ TreeNode("Theme", action=lambda: (pick_theme(ui, config), True)[1],
300
+ status_fn=lambda: config.theme),
301
+ TreeNode(
302
+ "Streaming",
303
+ action=lambda: _toggle_setting(ui, config, "stream", config.stream),
304
+ status_fn=lambda: "on" if config.stream else "off",
305
+ ),
306
+ ],
307
+ ),
308
+ TreeNode(
309
+ "History",
310
+ build=lambda: [
311
+ TreeNode("Browse Sessions",
312
+ action=lambda: (browse_history(ui, config), True)[1]),
313
+ TreeNode("Clear History", action=clear_history),
314
+ ],
315
+ ),
316
+ TreeNode(
317
+ "Models",
318
+ build=lambda: [
319
+ TreeNode(
320
+ "Max Tokens",
321
+ action=lambda: _edit_text_setting(
322
+ ui, config, "max_tokens", str(config.max_tokens)
323
+ ),
324
+ status_fn=lambda: str(config.max_tokens),
325
+ ),
326
+ ],
327
+ ),
328
+ TreeNode("Keyboard", build=lambda: [
329
+ TreeNode("Show Shortcuts", action=show_shortcuts),
330
+ ]),
331
+ TreeNode(
332
+ "Advanced",
333
+ build=lambda: [
334
+ TreeNode(
335
+ "Username",
336
+ action=lambda: _edit_text_setting(
337
+ ui, config, "username", config.username
338
+ ),
339
+ status_fn=lambda: config.username,
340
+ ),
341
+ TreeNode(
342
+ "Ollama Host",
343
+ action=lambda: _edit_text_setting(
344
+ ui, config, "ollama_host", config.ollama_host
345
+ ),
346
+ status_fn=lambda: config.ollama_host,
347
+ ),
348
+ TreeNode("Permission Mode",
349
+ action=lambda: _choose_permission(ui, config),
350
+ status_fn=lambda: config.permission_mode),
351
+ ],
352
+ ),
353
+ ],
354
+ )
355
+ navigate(root)
356
+
357
+
358
+ @command("settings", "Open interactive settings (or /settings <name> <value>)")
359
+ def _settings(ctx: CommandContext, arg: str) -> CommandResult:
360
+ parts = arg.split(maxsplit=1)
361
+ if len(parts) >= 2:
362
+ apply_setting(ctx.ui, ctx.config, parts[0].lower(), parts[1].strip())
363
+ return CommandResult()
364
+ settings_menu(ctx.ui, ctx.config)
365
+ return CommandResult()
@@ -0,0 +1,100 @@
1
+ """/palette and /files — the command palette (Ctrl+K) and project search (Ctrl+P).
2
+
3
+ The palette lists every high-level action (Change Provider, Change Model,
4
+ Settings, History, Doctor, Clear History, About, Theme, Assist Mode, Search
5
+ Projects) as a fuzzy-searchable selector, VS Code style. The chat REPL
6
+ binds Ctrl+K / Ctrl+P to these commands so they open mid-conversation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from ..ui.palette import PaletteAction, command_palette
14
+ from ..ui.searchbox import search_files
15
+ from . import CommandContext, CommandResult, command, dispatch
16
+
17
+
18
+ def _actions(ctx: CommandContext) -> list[PaletteAction]:
19
+ assist_on = ctx.config.agent_mode
20
+ return [
21
+ PaletteAction("Change Provider", "/provider", detail="switch AI backend"),
22
+ PaletteAction("Change Model", "/model", detail="browse the live catalogue"),
23
+ PaletteAction("Settings", "/settings", detail="interactive settings"),
24
+ PaletteAction("History", "/history", detail="browse saved sessions"),
25
+ PaletteAction("Doctor", "/doctor", detail="diagnose configuration and network"),
26
+ PaletteAction("Clear History", "__clear_history__",
27
+ detail="delete this provider's saved sessions"),
28
+ PaletteAction("About", "/about", detail="version and credits"),
29
+ PaletteAction("Theme", "/theme", detail="pick a colour theme (live preview)"),
30
+ PaletteAction(
31
+ "Assist Mode",
32
+ f"/assist {'off' if assist_on else 'on'}",
33
+ detail=f"currently {'on' if assist_on else 'off'}",
34
+ ),
35
+ PaletteAction("Search Projects", "__files__", detail="fuzzy project file search"),
36
+ PaletteAction("Keyboard Shortcuts", "/shortcuts", detail="key reference"),
37
+ PaletteAction("Clear Screen", "/clear", detail="wipe the terminal"),
38
+ ]
39
+
40
+
41
+ def open_palette(ctx: CommandContext) -> CommandResult:
42
+ """Open the command palette and run the chosen action."""
43
+ chosen = command_palette(_actions(ctx))
44
+ if chosen is None:
45
+ return CommandResult()
46
+ if chosen == "__files__":
47
+ return open_file_search(ctx)
48
+ if chosen == "__clear_history__":
49
+ from ..memory import delete_session, list_sessions
50
+ from ..ui.dialog import confirm_dialog
51
+
52
+ sessions = list_sessions(ctx.config.provider)
53
+ if not sessions:
54
+ ctx.ui.dim("No saved sessions to clear.")
55
+ return CommandResult()
56
+ if confirm_dialog(
57
+ f"Delete all {len(sessions)} saved sessions?",
58
+ yes_label="Delete All",
59
+ no_label="Keep",
60
+ danger=True,
61
+ ):
62
+ removed = sum(
63
+ 1 for sid, _ in sessions if delete_session(ctx.config.provider, sid)
64
+ )
65
+ ctx.ui.success(f"Removed {removed} sessions.")
66
+ return CommandResult()
67
+ return dispatch(ctx, str(chosen))
68
+
69
+
70
+ def open_file_search(ctx: CommandContext) -> CommandResult:
71
+ """Open the Ctrl+P project file search; show the chosen file."""
72
+ chosen = search_files(Path.cwd())
73
+ if chosen is None:
74
+ return CommandResult()
75
+ path = Path.cwd() / chosen
76
+ try:
77
+ text = path.read_text(encoding="utf-8", errors="replace")
78
+ except OSError as exc:
79
+ ctx.ui.error(f"Cannot open {chosen}: {exc}")
80
+ return CommandResult()
81
+ lines = text.splitlines()
82
+ from rich.syntax import Syntax
83
+
84
+ snippet = "\n".join(lines[:80])
85
+ body = Syntax(snippet, Syntax.guess_lexer(str(path), snippet),
86
+ theme="ansi_dark", line_numbers=True)
87
+ ctx.ui.panel(body, title=str(chosen))
88
+ if len(lines) > 80:
89
+ ctx.ui.dim(f"({len(lines) - 80} more lines — showing the first 80)")
90
+ return CommandResult()
91
+
92
+
93
+ @command("palette", "Open the command palette (Ctrl+K)", aliases=("commands",))
94
+ def _palette(ctx: CommandContext, arg: str) -> CommandResult:
95
+ return open_palette(ctx)
96
+
97
+
98
+ @command("files", "Search project files (Ctrl+P)", aliases=("search",))
99
+ def _files(ctx: CommandContext, arg: str) -> CommandResult:
100
+ return open_file_search(ctx)