seedcode-cli 6.2.0__py3-none-any.whl → 6.2.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 (42) hide show
  1. seedcode/__init__.py +1 -1
  2. seedcode/_default_key_template.py +5 -1
  3. seedcode/app.py +129 -17
  4. seedcode/branding.py +6 -1
  5. seedcode/cli.py +11 -3
  6. seedcode/commands/__init__.py +4 -2
  7. seedcode/commands/agent.py +7 -2
  8. seedcode/commands/assist.py +2 -1
  9. seedcode/commands/chat.py +46 -0
  10. seedcode/commands/codemode.py +3 -2
  11. seedcode/commands/mode.py +50 -0
  12. seedcode/commands/provider.py +52 -16
  13. seedcode/commands/status.py +95 -0
  14. seedcode/config/__init__.py +20 -2
  15. seedcode/config/defaults.py +29 -2
  16. seedcode/config/manager.py +177 -21
  17. seedcode/core/agent.py +30 -2
  18. seedcode/core/chat.py +33 -3
  19. seedcode/core/http.py +114 -5
  20. seedcode/core/models.py +46 -14
  21. seedcode/core/providers/__init__.py +34 -7
  22. seedcode/core/providers/base.py +24 -2
  23. seedcode/core/providers/default.py +157 -0
  24. seedcode/core/providers/ollama.py +9 -1
  25. seedcode/core/providers/openrouter.py +80 -29
  26. seedcode/default_api.py +54 -3
  27. seedcode/defaults.py +37 -0
  28. seedcode/tools/terminal.py +30 -5
  29. seedcode/ui/__init__.py +16 -3
  30. seedcode/ui/dashboard.py +98 -291
  31. seedcode/ui/menu.py +4 -0
  32. seedcode/ui/reference.py +46 -0
  33. seedcode/ui/selector.py +24 -0
  34. seedcode/utils/terminal_env.py +225 -0
  35. seedcode_cli-6.2.5.dist-info/METADATA +457 -0
  36. {seedcode_cli-6.2.0.dist-info → seedcode_cli-6.2.5.dist-info}/RECORD +39 -34
  37. {seedcode_cli-6.2.0.dist-info → seedcode_cli-6.2.5.dist-info}/WHEEL +1 -1
  38. seedcode/ui/banner.py +0 -78
  39. seedcode/ui/logo.py +0 -134
  40. seedcode_cli-6.2.0.dist-info/METADATA +0 -401
  41. {seedcode_cli-6.2.0.dist-info → seedcode_cli-6.2.5.dist-info}/entry_points.txt +0 -0
  42. {seedcode_cli-6.2.0.dist-info → seedcode_cli-6.2.5.dist-info}/licenses/LICENSE +0 -0
seedcode/__init__.py CHANGED
@@ -6,7 +6,7 @@ Plant ideas. Grow code.
6
6
  # Single source of truth for the application version. Every consumer reads it:
7
7
  # the PyPI build (via [tool.hatch.version]), the CLI --version flag, the
8
8
  # Windows installer metadata, the GitHub release tag, and the WinGet manifest.
9
- __version__ = "6.2.0"
9
+ __version__ = "6.2.5"
10
10
  __author__ = "Al shahriar sowan"
11
11
  __publisher__ = "Eagox Studio"
12
12
 
@@ -1,10 +1,14 @@
1
- """Embedded default OpenRouter credential (v6.2.0 build-time artifact).
1
+ """Embedded built-in credential for the Default provider (v6.2.5 artifact).
2
2
 
3
3
  This file is NOT committed to source control. The release build generates
4
4
  ``seedcode/_default_key.py`` from the local ``.env`` (see
5
5
  ``scripts/windows/embed_default_key.py``). Source checkouts without that
6
6
  generated file fall back to the empty default below, so behavior is
7
7
  unchanged for developers and CI.
8
+
9
+ The credential belongs to the built-in ``Default`` provider and is resolved
10
+ per request by :mod:`seedcode.core.providers.default`; it is never copied
11
+ into another provider's stored configuration.
8
12
  """
9
13
 
10
14
  DEFAULT_OPENROUTER_API_KEY = ""
seedcode/app.py CHANGED
@@ -14,9 +14,12 @@ Ctrl+L clear screen.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import sys
18
+
17
19
  from prompt_toolkit import PromptSession
18
20
  from prompt_toolkit.key_binding import KeyBindings
19
21
 
22
+ from . import __version__
20
23
  from .commands import CommandContext, dispatch, is_command
21
24
  from .commands.about import show_about
22
25
  from .commands.help import show_shortcuts
@@ -29,13 +32,14 @@ from .core.agent import AgentEngine, strip_tool_blocks
29
32
  from .core.chat import ChatEngine, ChatError
30
33
  from .core.lifecycle import lifecycle
31
34
  from .core.models import AppConfig
32
- from .core.providers import PROVIDERS, provider_label
35
+ from .core.providers import PROVIDERS, provider_label, provider_ready
33
36
  from .core.providers.freemodel import AUTO_MODEL
34
37
  from .memory import HistoryStore
35
38
  from .tools import PermissionManager, PermissionMode
36
39
  from .ui import UI
37
40
  from .ui.badges import badge_for_status
38
41
  from .ui.menu import MenuItem, run_menu
42
+ from .ui.reference import CONTROLS_HINT, INPUT_HINT
39
43
  from .ui.textbox import prompt_label
40
44
  from .ui.theme import pt_style, set_active_theme
41
45
  from .utils.logger import get_logger
@@ -102,9 +106,14 @@ def _release_desktop_resources() -> None:
102
106
 
103
107
 
104
108
  def _provider_status(config: AppConfig) -> str:
105
- """Menu status line: the active provider, or 'Not Configured'."""
106
- ready = config.provider == "ollama" or bool(config.get_api_key().strip())
107
- return provider_label(config.provider) if ready else "Not Configured"
109
+ """Menu status line: the active provider, or 'Not Configured'.
110
+
111
+ Uses the shared provider-ready rule, so the menu never disagrees with the
112
+ dashboard: Default and Ollama need no key, everyone else needs their own.
113
+ """
114
+ if provider_ready(config.provider, config):
115
+ return provider_label(config.provider)
116
+ return "Not Configured"
108
117
 
109
118
 
110
119
  def _model_status(config: AppConfig) -> str:
@@ -124,27 +133,75 @@ def _key_status(config: AppConfig) -> str:
124
133
  return config.masked_key()
125
134
 
126
135
 
136
+ def _mode_status(config: AppConfig) -> str:
137
+ """Menu status for the Code Mode item (ON only when really active)."""
138
+ try:
139
+ from .codemode_state import codemode_state
140
+
141
+ if codemode_state().enabled:
142
+ return "ON"
143
+ except Exception:
144
+ pass
145
+ return "ON" if config.agent_mode else "OFF"
146
+
147
+
127
148
  def _main_menu(config: AppConfig):
128
- """The interactive main menu; returns an action id or None (exit)."""
149
+ """The interactive main menu; returns an action id or None (exit).
150
+
151
+ v6.2.5 reference layout: the six mode/setup actions carry Ctrl+1..Ctrl+6
152
+ shortcuts that execute the real handlers (no decorative items), followed
153
+ by the chat/setup actions kept from earlier releases.
154
+ """
129
155
  provider = PROVIDERS.get(config.provider)
130
156
  badge = badge_for_status(provider.status if provider is not None else "")
131
157
  return run_menu(
132
158
  [
159
+ MenuItem("Code Mode", "codemode", status=_mode_status(config), shortcut="1"),
160
+ MenuItem("Agent Mode", "agent", shortcut="2"),
161
+ MenuItem("Assist Mode", "assist", shortcut="3"),
162
+ MenuItem("Project Memory", "memory", shortcut="4"),
163
+ MenuItem("Settings", "settings", shortcut="5"),
164
+ MenuItem("Exit", "exit", shortcut="6"),
133
165
  MenuItem("Start Chat", "chat", status=_model_status(config), badge=badge),
134
166
  MenuItem("Provider", "provider", status=_provider_status(config)),
135
167
  MenuItem("API Key", "apikey", status=_key_status(config)),
136
168
  MenuItem("Model", "model", status=_model_status(config)),
137
- MenuItem("Settings", "settings"),
138
169
  MenuItem("Theme", "theme", status=config.theme),
139
170
  MenuItem("About", "about"),
140
- MenuItem("Exit", "exit"),
141
171
  ],
142
- title="Seed Code",
143
- hint="↑↓ move type to filter Enter select Esc exit",
172
+ title=f"Seed Code v{__version__}",
173
+ hint=CONTROLS_HINT,
144
174
  initial="chat",
145
175
  )
146
176
 
147
177
 
178
+ def _dispatch_command(ui: UI, config: AppConfig, engine, command: str) -> None:
179
+ """Run a slash command from a menu action through the real router."""
180
+ dispatch(CommandContext(ui=ui, config=config, engine=engine), command)
181
+
182
+
183
+ def _report_command_error(ui: UI, exc: Exception) -> None:
184
+ """Render a failed command/action without leaking a raw traceback.
185
+
186
+ A transport failure becomes one actionable ``[Network Error]`` line, mapped
187
+ by :mod:`seedcode.core.http` (which never includes request headers, so an
188
+ API key can never surface here). Anything else keeps its own message; the
189
+ full traceback is already written to the log file.
190
+ """
191
+ try:
192
+ import httpx
193
+
194
+ from .core.http import friendly_error
195
+
196
+ if isinstance(exc, httpx.HTTPError):
197
+ ui.error(f"[Network Error] {friendly_error(exc)}")
198
+ ui.dim("Action: check your network connection or provider configuration.")
199
+ return
200
+ except Exception:
201
+ pass # error reporting must never itself raise
202
+ ui.error(f"[Command Error] {exc}")
203
+
204
+
148
205
  def _guided_setup(ui: UI, config: AppConfig) -> bool:
149
206
  """Provider -> API key -> validate -> fetch models -> select -> save.
150
207
 
@@ -343,14 +400,11 @@ def _chat_loop(
343
400
  config: AppConfig,
344
401
  engine: ChatEngine,
345
402
  history: HistoryStore,
346
- session: PromptSession,
403
+ session: PromptSession | _PlainSession,
347
404
  ) -> None:
348
405
  """Interactive chat until /exit (returns to the main menu)."""
349
406
  ctx = CommandContext(ui=ui, config=config, engine=engine)
350
- ui.dim(
351
- "Type /help for commands, /exit for the menu — "
352
- "Ctrl+K palette, Ctrl+P files, Ctrl+/ shortcuts."
353
- )
407
+ ui.dim(INPUT_HINT)
354
408
 
355
409
  # The Assist engine is built lazily on the first assist-mode turn and
356
410
  # rebuilt when the permission or desktop mode changes (its system
@@ -398,7 +452,7 @@ def _chat_loop(
398
452
  continue
399
453
  except Exception as exc: # a broken command must not kill the REPL
400
454
  _log.exception("command failed: %s", text.split()[0])
401
- ui.error(f"Command failed: {exc}")
455
+ _report_command_error(ui, exc)
402
456
  continue
403
457
  if result.should_exit:
404
458
  return
@@ -435,6 +489,56 @@ def _chat_loop(
435
489
  _handle_chat(ui, engine, history, text)
436
490
 
437
491
 
492
+ def _interactive() -> bool:
493
+ """True when a real console is attached (mirrors ``ui.selector._interactive``).
494
+
495
+ prompt_toolkit needs a console/PTY it can drive. Piped, redirected, or
496
+ console-less hosts (CI, ``seedcode < file``, some Git Bash/MSYS sessions)
497
+ cannot provide one, and constructing a prompt there raises instead of
498
+ degrading — so every interactive entry point checks this first.
499
+ """
500
+ try:
501
+ return sys.stdin.isatty() and sys.stdout.isatty()
502
+ except (AttributeError, ValueError):
503
+ return False
504
+
505
+
506
+ class _PlainSession:
507
+ """Line-based stand-in for :class:`PromptSession` on non-console hosts.
508
+
509
+ The chat loop only needs ``prompt()``, so this shim keeps Seed Code usable
510
+ where prompt_toolkit cannot start. Reading raises ``EOFError`` /
511
+ ``KeyboardInterrupt`` exactly like the real session, so the loop's existing
512
+ Ctrl+C / Ctrl+D handling is unchanged.
513
+ """
514
+
515
+ def prompt(self, message=None, **_kwargs) -> str:
516
+ return input(_plain_text(message) or "> ")
517
+
518
+
519
+ def _plain_text(message) -> str:
520
+ """Literal text of a prompt message (a prompt_toolkit ``FormattedText``)."""
521
+ if isinstance(message, str):
522
+ return message
523
+ try:
524
+ return "".join(fragment[1] for fragment in message)
525
+ except Exception:
526
+ return ""
527
+
528
+
529
+ def _make_chat_session(ui: UI):
530
+ """A prompt_toolkit session when a console exists, else the plain shim."""
531
+ if not _interactive():
532
+ return _PlainSession()
533
+ try:
534
+ return _build_chat_session(ui)
535
+ except Exception:
536
+ # A console that exists but still cannot be driven (e.g. a MSYS/mintty
537
+ # TERM against a non-Windows console). Never fatal — degrade instead.
538
+ _log.warning("prompt_toolkit unavailable; using line-based input")
539
+ return _PlainSession()
540
+
541
+
438
542
  def _build_chat_session(ui: UI) -> PromptSession:
439
543
  """The chat PromptSession with the global shortcut bindings attached."""
440
544
  kb = KeyBindings()
@@ -480,7 +584,7 @@ def run(ui: UI) -> None:
480
584
  active_backend = config.provider
481
585
  engine = ChatEngine(config)
482
586
  history = HistoryStore(provider_id=active_backend)
483
- chat_session = _build_chat_session(ui)
587
+ chat_session = _make_chat_session(ui)
484
588
 
485
589
  # Best-effort teardown for the one legitimate shutdown path.
486
590
  lifecycle().on_shutdown(_release_desktop_resources)
@@ -530,6 +634,14 @@ def run(ui: UI) -> None:
530
634
  apikey_menu(ui, config)
531
635
  elif choice == "model":
532
636
  select_model(ui, config)
637
+ elif choice == "codemode":
638
+ _dispatch_command(ui, config, engine, "/codemode on")
639
+ elif choice == "agent":
640
+ _dispatch_command(ui, config, engine, "/agent on")
641
+ elif choice == "assist":
642
+ _dispatch_command(ui, config, engine, "/assist on")
643
+ elif choice == "memory":
644
+ _dispatch_command(ui, config, engine, "/codemode status")
533
645
  elif choice == "settings":
534
646
  settings_menu(ui, config)
535
647
  elif choice == "theme":
@@ -543,4 +655,4 @@ def run(ui: UI) -> None:
543
655
  ui.dim("Cancelled.")
544
656
  except Exception as exc: # menu actions must never crash the app
545
657
  _log.exception("menu action failed: %s", choice)
546
- ui.error(f"Something went wrong: {exc}")
658
+ _report_command_error(ui, exc)
seedcode/branding.py CHANGED
@@ -4,9 +4,14 @@ The official Seed Code mark is the 16x16 pixel-art "sprouting seed" on a
4
4
  dark rounded tile (the Seed Green identity). This module owns the grid and
5
5
  the exact palette so every surface renders identical art:
6
6
 
7
- * ``seedcode/ui/logo.py`` — terminal rendering (half-block pixels)
8
7
  * ``scripts/windows/build_assets.py`` — ``seedcode.ico``, the Inno wizard
9
8
  bitmaps, the canonical ``assets/logo.png`` and the preview render
9
+ * the installer wizard and the Windows taskbar/Explorer surfaces
10
+
11
+ v6.2.5 note: the interactive CLI no longer renders any ASCII art. The mark
12
+ is a packaging/desktop asset only (:mod:`seedcode.ui.dashboard` renders a
13
+ borderless text header with no logo), so this module is not imported by the
14
+ terminal UI at all.
10
15
 
11
16
  Stdlib only (struct/zlib): safe to import from anywhere in the package,
12
17
  costs nothing at startup, and keeps the brand byte-reproducible.
seedcode/cli.py CHANGED
@@ -51,7 +51,8 @@ def main() -> None:
51
51
  if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
52
52
  from . import __version__
53
53
 
54
- print(f"Seed Code v{__version__}")
54
+ # Installers verify the install by parsing this exact line.
55
+ print(f"Seed Code CLI {__version__}")
55
56
  return
56
57
  if len(sys.argv) > 1 and sys.argv[1] in ("--help", "-h"):
57
58
  print(_HELP)
@@ -65,18 +66,25 @@ def main() -> None:
65
66
  log = get_logger("cli")
66
67
 
67
68
  from . import __version__
69
+ from .utils.terminal_env import detect_terminal, workspace_root
68
70
 
71
+ # Detect the terminal host/shell once (VS Code, Windows Terminal, Git Bash
72
+ # or unknown) so a support log says exactly where this session ran. The
73
+ # workspace root is the launch directory and is never changed below.
74
+ terminal = detect_terminal()
69
75
  log.info(
70
- "Seed Code v%s starting (python %s on %s)",
76
+ "Seed Code v%s starting (python %s on %s); terminal %s; cwd=%s",
71
77
  __version__,
72
78
  sys.version.split()[0],
73
79
  sys.platform,
80
+ terminal.describe(),
81
+ workspace_root(),
74
82
  )
75
83
 
76
84
  from .app import run
77
85
  from .ui import UI
78
86
 
79
- ui = UI()
87
+ ui = UI(plain=terminal.plain)
80
88
  try:
81
89
  run(ui)
82
90
  log.info("Clean exit.")
@@ -62,14 +62,16 @@ def dispatch(ctx: CommandContext, text: str) -> CommandResult:
62
62
 
63
63
  entry = _REGISTRY.get(name)
64
64
  if entry is None:
65
- ctx.ui.warning(f"Unknown command: /{name}. Type /help for the list.")
65
+ # Never silently ignore a command: name it, then point at /help.
66
+ ctx.ui.warning(f"[Command Error] Unknown command: /{name}")
67
+ ctx.ui.dim("Run /help for available commands.")
66
68
  return CommandResult(handled=True)
67
69
  return entry[0](ctx, arg)
68
70
 
69
71
 
70
72
  # Import handler modules for their registration side effects. Deferred to the
71
73
  # bottom so ``command`` / ``_REGISTRY`` already exist when the handlers load.
72
- from . import about, agent, assist, clear, codemode, desktop, doctor, help, history, palette, provider, theme # noqa: E402,F401
74
+ from . import about, agent, assist, chat, clear, codemode, desktop, doctor, help, history, mode, palette, provider, status, theme # noqa: E402,F401
73
75
 
74
76
  __all__ = [
75
77
  "CommandContext",
@@ -23,7 +23,8 @@ def _agent(ctx: CommandContext, arg: str) -> CommandResult:
23
23
  elif not raw:
24
24
  enable = not ctx.config.agent_mode # bare /agent toggles
25
25
  else:
26
- ctx.ui.warning("Usage: /agent [on|off]")
26
+ ctx.ui.warning("[Command Error] Invalid syntax.")
27
+ ctx.ui.dim("Expected: /agent on|off")
27
28
  return CommandResult()
28
29
 
29
30
  # Agent Mode was merged into Assist Mode — route there transparently.
@@ -35,7 +36,11 @@ def _agent(ctx: CommandContext, arg: str) -> CommandResult:
35
36
  return CommandResult()
36
37
 
37
38
 
38
- @command("permission", "Show or set the Assist permission mode", aliases=("perm",))
39
+ @command(
40
+ "permission",
41
+ "Show or set the Assist permission mode",
42
+ aliases=("perm", "permissions"),
43
+ )
39
44
  def _permission(ctx: CommandContext, arg: str) -> CommandResult:
40
45
  raw = arg.strip()
41
46
  detail = {
@@ -45,7 +45,8 @@ def _assist(ctx: CommandContext, arg: str) -> CommandResult:
45
45
  _show_status(ctx)
46
46
  return CommandResult()
47
47
  else:
48
- ctx.ui.warning("Usage: /assist [on|off]")
48
+ ctx.ui.warning("[Command Error] Invalid syntax.")
49
+ ctx.ui.dim("Expected: /assist on|off")
49
50
  return CommandResult()
50
51
 
51
52
  if enable:
@@ -0,0 +1,46 @@
1
+ """/chat — explicit Chat Mode (plain conversation, no tools).
2
+
3
+ /chat — show the current mode.
4
+ /chat on — leave Assist/Code Mode and return to plain Chat: the AI answers
5
+ with text only and never touches the filesystem or terminal.
6
+
7
+ Chat Mode is the default runtime mode; this command exists so a user can
8
+ always get back to it directly, without hunting through menus.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from . import CommandContext, CommandResult, command
14
+ from .status import mode_label
15
+
16
+
17
+ @command("chat", "Switch to plain Chat Mode. Usage: /chat [on]")
18
+ def _chat(ctx: CommandContext, arg: str) -> CommandResult:
19
+ raw = arg.strip().lower()
20
+ if raw in ("", "status"):
21
+ ctx.ui.info(f"Mode: {mode_label(ctx.config)}")
22
+ ctx.ui.dim("Switch with: /chat on · /assist on · /codemode on (or /mode chat|assist|code)")
23
+ return CommandResult()
24
+
25
+ if raw != "on":
26
+ ctx.ui.warning("[Command Error] Invalid syntax.")
27
+ ctx.ui.dim("Expected: /chat [on]")
28
+ return CommandResult()
29
+
30
+ _enter_chat(ctx)
31
+ return CommandResult()
32
+
33
+
34
+ def _enter_chat(ctx: CommandContext) -> None:
35
+ """Return the session to plain Chat Mode (idempotent)."""
36
+ from .. import codemode_state as cms
37
+ from .assist import disable_assist
38
+
39
+ if cms.codemode_state().enabled:
40
+ cms.disable()
41
+ ctx.ui.dim("Code Mode OFF — .seedcode memory kept for next session")
42
+
43
+ was_assist = ctx.config.agent_mode
44
+ if was_assist:
45
+ disable_assist(ctx.ui, ctx.config)
46
+ ctx.ui.success("Chat Mode ON — plain conversation, no tools")
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  from rich.table import Table
13
13
 
14
- from .. import codemode_state
14
+ from ..codemode_state import codemode_state
15
15
  from ..config import save_config
16
16
  from ..tools import PermissionMode
17
17
  from . import CommandContext, CommandResult, command
@@ -45,7 +45,8 @@ def _codemode(ctx: CommandContext, arg: str) -> CommandResult:
45
45
  _disable_codemode(ctx)
46
46
  return CommandResult()
47
47
 
48
- ctx.ui.warning("Usage: /codemode [on|off|status]")
48
+ ctx.ui.warning("[Command Error] Invalid syntax.")
49
+ ctx.ui.dim("Expected: /codemode on|off|status")
49
50
  return CommandResult()
50
51
 
51
52
 
@@ -0,0 +1,50 @@
1
+ """/mode — one generic switcher for the runtime modes.
2
+
3
+ /mode — show the current mode.
4
+ /mode chat — plain conversation (no tools).
5
+ /mode assist — the unified AI + tools mode (filesystem, terminal, git, ...).
6
+ /mode code — Assist sharpened for the current workspace (Code Mode).
7
+ /mode agent — legacy alias of assist.
8
+
9
+ The individual commands (/chat, /assist, /codemode) remain the direct routes;
10
+ this command is the single entry point that understands all of them.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from . import CommandContext, CommandResult, command
16
+ from .status import mode_label
17
+
18
+
19
+ @command("mode", "Show or switch the runtime mode. Usage: /mode [chat|assist|code|agent]")
20
+ def _mode(ctx: CommandContext, arg: str) -> CommandResult:
21
+ raw = arg.strip().lower()
22
+
23
+ if not raw:
24
+ ctx.ui.info(f"Mode: {mode_label(ctx.config)}")
25
+ ctx.ui.dim("Switch with: /mode chat|assist|code|agent")
26
+ return CommandResult()
27
+
28
+ if raw == "chat":
29
+ from .chat import _enter_chat
30
+
31
+ _enter_chat(ctx)
32
+ elif raw == "assist":
33
+ from .assist import enable_assist
34
+
35
+ enable_assist(ctx.ui, ctx.config)
36
+ elif raw == "code":
37
+ from .codemode import _enable_codemode
38
+
39
+ _enable_codemode(ctx)
40
+ elif raw == "agent":
41
+ # Agent Mode merged into Assist Mode; keep the name working.
42
+ from .assist import enable_assist
43
+
44
+ enable_assist(ctx.ui, ctx.config)
45
+ ctx.ui.dim("(Agent Mode is Assist Mode now — same engine, same tools)")
46
+ else:
47
+ ctx.ui.warning("[Command Error] Invalid syntax.")
48
+ ctx.ui.dim("Expected: /mode chat|assist|code|agent")
49
+
50
+ return CommandResult()
@@ -46,14 +46,45 @@ def _resolve_provider(text: str) -> Provider | None:
46
46
  return matches[0] if len(matches) == 1 else None
47
47
 
48
48
 
49
+ # Selector groups, in display order. Providers are grouped by what they need
50
+ # from the user, so "Default" is never mistaken for "OpenRouter" (they can
51
+ # share an underlying service but are separate choices with separate config).
52
+ _GROUP_BUILTIN = "Built-in · no API key needed"
53
+ _GROUP_BYOK = "Your own API key"
54
+ _GROUP_LOCAL = "Local · runs on this machine"
55
+
56
+
57
+ def _provider_group(provider: Provider) -> str:
58
+ if provider.local:
59
+ return _GROUP_LOCAL
60
+ if provider.id == "default":
61
+ return _GROUP_BUILTIN
62
+ return _GROUP_BYOK
63
+
64
+
49
65
  def _provider_backend(provider: Provider, config) -> str:
50
- if provider.id == "ollama":
66
+ """Human connection type: 'Local' for on-machine backends, else the API family."""
67
+ if provider.local:
51
68
  return "Local"
52
69
  return provider.backend_label or f"{provider.label} API"
53
70
 
54
71
 
72
+ def _provider_key_column(provider: Provider, config) -> str:
73
+ """What this provider needs from the user: no key, a masked key, or a prompt.
74
+
75
+ This is the column that makes the API-key rules visible before switching:
76
+ Default and Ollama read "no API key", configured providers show their
77
+ masked key, and the rest ask for one. A key is never shown in full.
78
+ """
79
+ if not provider.requires_key:
80
+ return "no API key"
81
+ if config.get_api_key(provider.id).strip():
82
+ return config.masked_key(provider.id)
83
+ return "API key needed"
84
+
85
+
55
86
  def _provider_menu(ui, config) -> Provider | None:
56
- """Interactive provider selector: badge, backend, and current model."""
87
+ """Interactive provider selector: badge, backend, model, and key state."""
57
88
  options = []
58
89
  for p in PROVIDERS.values():
59
90
  entry = config.providers.get(p.id)
@@ -65,7 +96,12 @@ def _provider_menu(ui, config) -> Provider | None:
65
96
  p.label,
66
97
  value=p.id,
67
98
  badge=badge_for_status(p.status),
68
- columns=(_provider_backend(p, config), model),
99
+ columns=(
100
+ _provider_backend(p, config),
101
+ model,
102
+ _provider_key_column(p, config),
103
+ ),
104
+ group=_provider_group(p),
69
105
  )
70
106
  )
71
107
  chosen = select(
@@ -113,22 +149,22 @@ def _collect_key(ui, config, provider: Provider, *, replacing: bool = False) ->
113
149
 
114
150
 
115
151
  def _ensure_ready(ui, config, provider: Provider) -> bool:
116
- """Make ``provider`` usable: collect+validate a key, or detect Ollama.
152
+ """Make ``provider`` usable: collect+validate a key, or probe a key-less one.
117
153
 
118
- Returns False only when the user cancels key entry; a stopped Ollama
119
- server is reported but not fatal (the user may start it later).
154
+ Returns False only when the user cancels key entry. An unreachable
155
+ key-less backend (local Ollama, or the built-in Default connection in a
156
+ build that carries no credential) is reported with its own actionable
157
+ message but is never fatal — the user may start Ollama or pick another
158
+ provider later.
120
159
  """
121
160
  if not provider.requires_key:
122
- with ui.thinking("Checking Ollama"):
123
- running = provider.detect(config)
124
- provider.status = STATUS_CONNECTED if running else STATUS_OFFLINE
125
- if running:
126
- ui.success("Ollama server detected.")
161
+ with ui.thinking(f"Checking {provider.label}"):
162
+ reachable = provider.detect(config)
163
+ provider.status = STATUS_CONNECTED if reachable else STATUS_OFFLINE
164
+ if reachable:
165
+ ui.success(f"{provider.label} is ready.")
127
166
  else:
128
- ui.warning(
129
- f"Ollama is not reachable at {config.ollama_host}. "
130
- "Start it with 'ollama serve' — chatting will fail until it runs."
131
- )
167
+ ui.warning(provider.unavailable_hint(config))
132
168
  return True
133
169
 
134
170
  if config.get_api_key(provider.id).strip():
@@ -352,7 +388,7 @@ def select_model(ui, config, target: str = "") -> None:
352
388
  @command(
353
389
  "provider",
354
390
  "Select the active provider "
355
- "(OpenRouter, FreeModel Claude, FreeModel Codex, AeroLink, Ollama)",
391
+ "(Default, OpenRouter, FreeModel Claude, FreeModel Codex, AeroLink, Ollama)",
356
392
  )
357
393
  def _provider_cmd(ctx: CommandContext, arg: str) -> CommandResult:
358
394
  select_provider(ctx.ui, ctx.config, arg.strip())