symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,113 @@
1
+ """Learning modal screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from rich.console import Group
9
+ from rich.text import Text
10
+ from textual.containers import Container, Horizontal
11
+ from textual.widgets import Static, TabbedContent, TabPane
12
+
13
+ from coding_agent.addons.learning import LearningStore, Lesson
14
+ from coding_agent.tui.screens.modal import ModalBase, ModalCloseButton, ModalScroll
15
+ from coding_agent.tui.theme import LEARNING_MODAL_CSS
16
+
17
+ def _human_date(value: str) -> str:
18
+ try:
19
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
20
+ except ValueError:
21
+ return value
22
+ return parsed.strftime("%b %d, %Y")
23
+
24
+
25
+ class LearningCard(Container):
26
+ """A diff-modal-style lesson row with a compact header and body."""
27
+
28
+ def __init__(self, lesson: Lesson, index: int) -> None:
29
+ self.lesson = lesson
30
+ self.index = index
31
+ super().__init__(classes="learning-card")
32
+
33
+ def compose(self): # type: ignore[no-untyped-def]
34
+ lesson = self.lesson
35
+ confidence_color = "#79a985" if lesson.confidence >= 0.7 else "#d0a85c"
36
+ meta = Text()
37
+ meta.append(f"{round(lesson.confidence * 100)}%", style=f"bold {confidence_color}")
38
+ if lesson.created_at:
39
+ meta.append(f" {_human_date(lesson.created_at)}", style="#858d91")
40
+ if lesson.source_task:
41
+ meta.append(f" · {lesson.source_task}", style="#858d91")
42
+ with Horizontal(classes="learning-card-header"):
43
+ yield Static("◆", classes="learning-marker")
44
+ yield Static(f"lesson {self.index}", classes="learning-card-title")
45
+ yield Static(meta, classes="learning-meta")
46
+ rows: list[object] = []
47
+ self._append_items(rows, "WHEN TO USE", lesson.applicable_when, "#8eafc2", "›")
48
+ self._append_items(rows, "WORKED", lesson.worked, "#79a985", "+")
49
+ self._append_items(rows, "WATCH OUT", lesson.failed, "#c67b82", "−")
50
+ yield Static(Group(*rows), classes="learning-body-content")
51
+
52
+ @staticmethod
53
+ def _append_items(
54
+ rows: list[object], label: str, items: list[str], color: str, marker: str
55
+ ) -> None:
56
+ if not items:
57
+ return
58
+ rows.append(Text(f"\n{label}", style=color))
59
+ for item in items:
60
+ line = Text(" ")
61
+ line.append(f"{marker} ", style=color)
62
+ line.append(item, style="#aaaaaa")
63
+ rows.append(line)
64
+
65
+
66
+ # --- learning.py ---
67
+ class LearningModal(ModalBase[None]):
68
+ """Memory inspector separating current-session provenance from global facts."""
69
+
70
+ CSS = LEARNING_MODAL_CSS
71
+
72
+ def __init__(
73
+ self,
74
+ workspace: Path,
75
+ *,
76
+ session_id: str | None = None,
77
+ store: LearningStore | None = None,
78
+ ) -> None:
79
+ super().__init__()
80
+ self.workspace = workspace
81
+ self.session_id = session_id
82
+ self.store = store
83
+
84
+ def compose(self): # type: ignore[no-untyped-def]
85
+ store = self.store or LearningStore(self.workspace, session_id=self.session_id)
86
+ session = self.session_id or store.session_id
87
+ self.store = store
88
+ with Container(id="learning-pane", classes="modal-pane"):
89
+ with Horizontal(id="learning-header"):
90
+ yield Static("learning", id="learning-title")
91
+ yield Static(
92
+ f"session {session[:8]}" if session else "no active session",
93
+ id="learning-counter",
94
+ )
95
+ yield ModalCloseButton("esc close", id="modal-close")
96
+ with TabbedContent(initial="memory-session", id="learning-tabs"):
97
+ with TabPane("Session", id="memory-session"):
98
+ with ModalScroll(id="session-memory-body", classes="modal-body"):
99
+ yield Static(
100
+ store.scoped_markdown("session", session_id=session),
101
+ id="session-memory-content", classes="learning-content", markup=False,
102
+ )
103
+ with TabPane("Global", id="memory-global"):
104
+ with ModalScroll(id="global-memory-body", classes="modal-body"):
105
+ yield Static(
106
+ store.scoped_markdown("global"),
107
+ id="global-memory-content", classes="learning-content", markup=False,
108
+ )
109
+ yield Static(
110
+ "click tabs to switch · ↑↓ scroll · Esc close",
111
+ id="learning-hint",
112
+ classes="modal-footer",
113
+ )
@@ -0,0 +1,131 @@
1
+ """Thin modal host widgets shared by TUI screens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Generic, TypeVar
6
+
7
+ from rich.console import Group
8
+ from rich.text import Text
9
+ from textual import events
10
+ from textual.binding import Binding
11
+ from textual.app import ScreenStackError
12
+ from textual.containers import Container, VerticalScroll
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Static
15
+
16
+ from coding_agent.tui.theme import CONTENT_MODAL_CSS
17
+
18
+ ResultT = TypeVar("ResultT")
19
+
20
+
21
+ class ModalCloseButton(Static, can_focus=True):
22
+ """Small top-right close control shared by all modal screens."""
23
+
24
+ def on_click(self, event: events.Click) -> None:
25
+ event.stop()
26
+ dismiss_overlay(self.screen)
27
+
28
+ def on_key(self, event: events.Key) -> None:
29
+ if event.key in {"enter", "space"}:
30
+ event.stop()
31
+ dismiss_overlay(self.screen)
32
+
33
+
34
+ class ModalScroll(VerticalScroll):
35
+ """Scrollable modal body that always reserves Escape for closing."""
36
+
37
+ BINDINGS = [
38
+ Binding("escape", "close_modal", "Close", show=False, priority=True),
39
+ ]
40
+
41
+ def action_close_modal(self) -> None:
42
+ dismiss_overlay(self.screen)
43
+
44
+ async def _on_key(self, event: events.Key) -> None:
45
+ if event.key == "escape":
46
+ event.stop()
47
+ dismiss_overlay(self.screen)
48
+ return
49
+ await super()._on_key(event)
50
+
51
+
52
+ class ModalBase(ModalScreen[ResultT], Generic[ResultT]):
53
+ """Base modal with a consistent close button and Escape behavior."""
54
+
55
+ BINDINGS = [
56
+ Binding("escape", "close_modal", "Close", show=False, priority=True),
57
+ ]
58
+
59
+ def action_close_modal(self) -> None:
60
+ dismiss_overlay(self)
61
+
62
+
63
+ def dismiss_overlay(screen: ModalScreen[object], result: object = None) -> bool:
64
+ """Dismiss an overlay only while it is still the active extra screen."""
65
+ app = screen.app
66
+ if not screen.is_current or len(app.screen_stack) <= 1:
67
+ return False
68
+ try:
69
+ screen.dismiss(result)
70
+ except ScreenStackError:
71
+ return False
72
+ return True
73
+
74
+
75
+ def _numbered_text(content: str) -> Text:
76
+ """Render plain text with a stable, compact line-number gutter.
77
+
78
+ This deliberately uses only Rich/Textual primitives; a full diff library is
79
+ unnecessary for displaying an existing document. ``difflib`` remains the
80
+ right stdlib tool for comparing two versions, as used by the diff tool.
81
+ """
82
+ # ``splitlines`` handles LF, CRLF, and lone CR without leaving carriage
83
+ # returns in the rendered source. It also preserves intentional blank lines
84
+ # between lines; a completely empty document still has one visible row.
85
+ lines = content.splitlines()
86
+ if not lines:
87
+ lines = [""]
88
+ width = max(2, len(str(len(lines))))
89
+ rendered = Text(no_wrap=True, overflow="crop")
90
+ for number, line in enumerate(lines, start=1):
91
+ rendered.append(f"{number:>{width}} │ ", style="#687e8b")
92
+ rendered.append(line, style="#d0d0d0")
93
+ if number != len(lines):
94
+ rendered.append("\n")
95
+ return rendered
96
+
97
+
98
+ class ContentModal(ModalBase[None]):
99
+ """Modal used to inspect transcript content with a code-style gutter."""
100
+
101
+ CSS = CONTENT_MODAL_CSS
102
+
103
+ def __init__(self, content: str) -> None:
104
+ super().__init__()
105
+ self.content = content
106
+
107
+ def compose(self): # type: ignore[no-untyped-def]
108
+ with Container(id="content-pane", classes="modal-pane"):
109
+ yield ModalCloseButton("Esc", id="modal-close")
110
+ yield Static("Text preview", id="content-title")
111
+ with ModalScroll(id="content-body", classes="modal-body"):
112
+ # Keep the raw node for integrations that inspect modal content;
113
+ # the numbered node is the visible review surface.
114
+ yield Static(self.content, id="content-text", markup=False)
115
+ yield Static(_numbered_text(self.content), id="content-numbered", markup=False)
116
+ yield Static("↑↓ scroll · Esc close", classes="modal-footer")
117
+
118
+ # --- components.py ---
119
+ class EmptyState(Static):
120
+ """Friendly empty state shared by data-backed modal screens."""
121
+
122
+ def __init__(self, title: str, detail: str, **kwargs: object) -> None:
123
+ super().__init__(
124
+ Group(
125
+ Text("◇", style="bold #738794"),
126
+ Text(title, style="bold #c8c8c8"),
127
+ Text(detail, style="#6f6f6f"),
128
+ ),
129
+ classes="empty-state",
130
+ **kwargs,
131
+ )
@@ -0,0 +1,455 @@
1
+ """First-run and in-session wizard for adding model provider credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from rich.text import Text
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Container, Vertical
13
+ from textual.message import Message
14
+ from textual.widgets import Input, OptionList, Static
15
+ from textual.widgets.option_list import Option
16
+
17
+ from coding_agent.config.credentials import save_provider_auth, save_provider_settings
18
+ from coding_agent.tui.screens.modal import ModalBase, ModalCloseButton, dismiss_overlay
19
+ from coding_agent.tui.theme import ONBOARD_CSS, PROVIDER_MODAL_CSS, SYMPHONY_RICH_THEME
20
+ from core_ai.oauth import LoginCancelled, LoginFlow, save_token, start_login
21
+ from core_ai.providers.catalog import (
22
+ PROVIDERS,
23
+ ProviderSpec,
24
+ configured_provider_ids,
25
+ get_provider,
26
+ provider_api_key,
27
+ )
28
+
29
+
30
+ def _provider_row(spec: ProviderSpec, configured: bool) -> Option:
31
+ content = Text("› ")
32
+ content.append(spec.label)
33
+ content.append("\n ")
34
+ if configured:
35
+ content.append("ready", style="#799e7c")
36
+ badge = spec.base_url_env if not spec.requires_key and spec.base_url_env else spec.env_key
37
+ if spec.supports_oauth:
38
+ badge = f"{badge} or subscription"
39
+ content.append(f" {badge}", style="#555555")
40
+ else:
41
+ content.append(spec.description, style="#737373")
42
+ return Option(content, id=f"provider:{spec.id}")
43
+
44
+
45
+ def _continue_row(configured: tuple[str, ...]) -> Option:
46
+ labels = ", ".join(get_provider(provider_id).label for provider_id in configured)
47
+ content = Text("Continue")
48
+ content.append("\n ")
49
+ content.append(f"Use {labels}", style="#737373")
50
+ return Option(content, id="continue")
51
+
52
+
53
+ def _method_row(method_id: str, title: str, detail: str) -> Option:
54
+ content = Text("› ")
55
+ content.append(title)
56
+ content.append("\n ")
57
+ content.append(detail, style="#737373")
58
+ return Option(content, id=f"method:{method_id}")
59
+
60
+
61
+ def _oauth_methods(spec: ProviderSpec) -> list[Option]:
62
+ if spec.id == "openai":
63
+ oauth = _method_row("oauth", "Sign in with ChatGPT", "Use a ChatGPT Plus / Pro / Codex subscription")
64
+ elif spec.id == "anthropic":
65
+ oauth = _method_row("oauth", "Sign in with Claude", "Paste a setup-token or browser code")
66
+ else:
67
+ oauth = _method_row("oauth", "Sign in with xAI", "Device login for SuperGrok / Premium+")
68
+ if not spec.requires_key:
69
+ key_label = "Paste endpoint"
70
+ elif provider_api_key(spec):
71
+ key_label = "Use existing API key"
72
+ else:
73
+ key_label = "Paste API key"
74
+ return [oauth, _method_row("key", key_label, spec.env_key if spec.requires_key else spec.description)]
75
+
76
+
77
+ class ProviderWizard(Vertical):
78
+ """Pick a provider, choose sign-in vs key, then continue."""
79
+
80
+ class Completed(Message):
81
+ def __init__(self, providers: tuple[str, ...]) -> None:
82
+ super().__init__()
83
+ self.providers = providers
84
+
85
+ def __init__(
86
+ self,
87
+ workspace: str | Path,
88
+ *,
89
+ initial_provider: str | None = None,
90
+ first_run: bool = False,
91
+ **kwargs: object,
92
+ ) -> None:
93
+ super().__init__(**kwargs)
94
+ self.workspace = Path(workspace)
95
+ self.initial_provider = initial_provider
96
+ self.first_run = first_run
97
+ self.step: Literal["pick", "method", "key", "oauth"] = "pick"
98
+ self._pending: str | None = None
99
+ self._highlight_continue = False
100
+ self._flow: LoginFlow | None = None
101
+ self._oauth_done = False
102
+
103
+ def compose(self) -> ComposeResult:
104
+ yield Static("Add a provider", id="onboard-title")
105
+ yield Static(
106
+ "Choose where Symphony should send model calls. You can add more than one.",
107
+ id="onboard-subtitle",
108
+ )
109
+ yield Static("", id="onboard-detail")
110
+ yield OptionList(id="provider-list")
111
+ yield Input(password=True, id="provider-key")
112
+ yield Static("", id="onboard-error")
113
+ yield Static(self._hint_text(), id="onboard-hint")
114
+
115
+ def on_mount(self) -> None:
116
+ self.query_one("#provider-key", Input).display = False
117
+ self.query_one("#onboard-detail", Static).display = False
118
+ if self.initial_provider:
119
+ spec = get_provider(self.initial_provider)
120
+ if spec.supports_oauth:
121
+ self.show_method(self.initial_provider)
122
+ else:
123
+ self.show_key(self.initial_provider)
124
+ else:
125
+ self.show_pick()
126
+
127
+ def show_pick(self) -> None:
128
+ self._close_flow()
129
+ self.step = "pick"
130
+ self._pending = None
131
+ self.query_one("#onboard-title", Static).update("Add a provider")
132
+ self.query_one("#onboard-subtitle", Static).update(
133
+ "Choose where Symphony should send model calls. You can add more than one."
134
+ )
135
+ self.query_one("#onboard-error", Static).update("")
136
+ self.query_one("#onboard-detail", Static).update("")
137
+ self.query_one("#onboard-detail", Static).display = False
138
+ self.query_one("#provider-key", Input).display = False
139
+ listing = self.query_one("#provider-list", OptionList)
140
+ listing.display = True
141
+ self._refresh_list()
142
+ listing.focus()
143
+ self._set_hint()
144
+
145
+ def show_method(self, provider_id: str) -> None:
146
+ spec = get_provider(provider_id)
147
+ if not spec.supports_oauth:
148
+ self.show_key(provider_id)
149
+ return
150
+ self._close_flow()
151
+ self.step = "method"
152
+ self._pending = provider_id
153
+ self.query_one("#onboard-title", Static).update(spec.label)
154
+ self.query_one("#onboard-subtitle", Static).update(
155
+ "Sign in with a subscription, or use an API key."
156
+ )
157
+ self.query_one("#onboard-error", Static).update("")
158
+ self.query_one("#onboard-detail", Static).display = False
159
+ self.query_one("#provider-key", Input).display = False
160
+ listing = self.query_one("#provider-list", OptionList)
161
+ listing.display = True
162
+ listing.set_options(_oauth_methods(spec))
163
+ listing.highlighted = 0
164
+ listing.focus()
165
+ self._set_hint()
166
+
167
+ def show_key(self, provider_id: str) -> None:
168
+ spec = get_provider(provider_id)
169
+ self._close_flow()
170
+ self.step = "key"
171
+ self._pending = provider_id
172
+ key_input = self.query_one("#provider-key", Input)
173
+ if spec.requires_key:
174
+ self.query_one("#onboard-title", Static).update(f"{spec.label} API key")
175
+ self.query_one("#onboard-subtitle", Static).update(
176
+ f"Paste a key from {spec.docs_url}"
177
+ )
178
+ key_input.password = True
179
+ key_input.placeholder = spec.key_placeholder
180
+ else:
181
+ self.query_one("#onboard-title", Static).update(f"{spec.label} endpoint")
182
+ self.query_one("#onboard-subtitle", Static).update(spec.description)
183
+ key_input.password = False
184
+ key_input.placeholder = spec.default_base_url or spec.key_placeholder
185
+ self.query_one("#onboard-error", Static).update("")
186
+ self.query_one("#onboard-detail", Static).display = False
187
+ self.query_one("#provider-list", OptionList).display = False
188
+ key_input.value = ""
189
+ key_input.display = True
190
+ key_input.focus()
191
+ self._set_hint()
192
+
193
+ def show_oauth(self, provider_id: str) -> None:
194
+ self._close_flow()
195
+ self.step = "oauth"
196
+ self._pending = provider_id
197
+ self._oauth_done = False
198
+ try:
199
+ flow = start_login(provider_id)
200
+ except Exception as exc:
201
+ # Do not leave a preference behind when login could not start.
202
+ self.show_method(provider_id)
203
+ self.query_one("#onboard-error", Static).update(str(exc))
204
+ return
205
+ self._flow = flow
206
+ prompt = flow.prompt
207
+ self.query_one("#onboard-title", Static).update(prompt.title)
208
+ if prompt.user_code:
209
+ self.query_one("#onboard-subtitle", Static).update(f"Code: {prompt.user_code}")
210
+ else:
211
+ self.query_one("#onboard-subtitle", Static).update("Finish in the browser, or paste below.")
212
+ detail_parts = [prompt.instructions]
213
+ if prompt.url:
214
+ detail_parts.append(prompt.url)
215
+ detail_widget = self.query_one("#onboard-detail", Static)
216
+ detail_widget.update("\n".join(detail_parts))
217
+ detail_widget.display = True
218
+ self.query_one("#onboard-error", Static).update("")
219
+ self.query_one("#provider-list", OptionList).display = False
220
+ key_input = self.query_one("#provider-key", Input)
221
+ if prompt.kind == "device":
222
+ key_input.display = False
223
+ else:
224
+ key_input.password = False
225
+ key_input.placeholder = prompt.paste_hint
226
+ key_input.value = ""
227
+ key_input.display = True
228
+ key_input.focus()
229
+ self._set_hint()
230
+ self.app.run_worker(self._wait_oauth(), exclusive=True)
231
+
232
+ def finish(self) -> None:
233
+ self._close_flow()
234
+ self.post_message(self.Completed(configured_provider_ids()))
235
+
236
+ def cancel(self) -> bool:
237
+ """Handle Escape. Returns True when the host should close."""
238
+ if self.step == "oauth":
239
+ pending = self._pending
240
+ self._close_flow()
241
+ if pending:
242
+ self.show_method(pending)
243
+ else:
244
+ self.show_pick()
245
+ return False
246
+ if self.step == "key":
247
+ pending = self._pending
248
+ if pending and get_provider(pending).supports_oauth:
249
+ self.show_method(pending)
250
+ else:
251
+ self.show_pick()
252
+ return False
253
+ if self.step == "method":
254
+ self.show_pick()
255
+ return False
256
+ self.finish()
257
+ return True
258
+
259
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
260
+ event.stop()
261
+ option_id = event.option_id or ""
262
+ if option_id == "continue":
263
+ self.finish()
264
+ return
265
+ if option_id.startswith("provider:"):
266
+ provider_id = option_id.split(":", 1)[1]
267
+ spec = get_provider(provider_id)
268
+ if spec.supports_oauth:
269
+ self.show_method(provider_id)
270
+ else:
271
+ self.show_key(provider_id)
272
+ return
273
+ if option_id == "method:oauth" and self._pending:
274
+ self.show_oauth(self._pending)
275
+ return
276
+ if option_id == "method:key" and self._pending:
277
+ spec = get_provider(self._pending)
278
+ if spec.requires_key and provider_api_key(spec):
279
+ save_provider_auth(self._pending, "key")
280
+ self._highlight_continue = True
281
+ self.show_pick()
282
+ return
283
+ self.show_key(self._pending)
284
+
285
+ def on_input_submitted(self, event: Input.Submitted) -> None:
286
+ event.stop()
287
+ if self.step == "oauth":
288
+ self._submit_oauth_paste(event.value)
289
+ return
290
+ if self._pending is None or self.step != "key":
291
+ return
292
+ spec = get_provider(self._pending)
293
+ try:
294
+ if spec.requires_key:
295
+ save_provider_settings(self._pending, api_key=event.value)
296
+ save_provider_auth(self._pending, "key")
297
+ else:
298
+ save_provider_settings(self._pending, base_url=event.value)
299
+ except ValueError as exc:
300
+ self.query_one("#onboard-error", Static).update(str(exc))
301
+ return
302
+ self._highlight_continue = True
303
+ self.show_pick()
304
+
305
+ def _submit_oauth_paste(self, pasted: str) -> None:
306
+ if self._flow is None:
307
+ return
308
+ try:
309
+ token = self._flow.complete_from_paste(pasted)
310
+ except ValueError as exc:
311
+ self.query_one("#onboard-error", Static).update(str(exc))
312
+ return
313
+ except Exception as exc:
314
+ self.query_one("#onboard-error", Static).update(str(exc))
315
+ return
316
+ self._finish_oauth(token)
317
+
318
+ async def _wait_oauth(self) -> None:
319
+ flow = self._flow
320
+ if flow is None:
321
+ return
322
+ try:
323
+ token = await asyncio.to_thread(flow.wait)
324
+ except LoginCancelled:
325
+ return
326
+ except Exception as exc:
327
+ self._oauth_failed(str(exc))
328
+ return
329
+ self._finish_oauth(token)
330
+
331
+ def _oauth_failed(self, message: str) -> None:
332
+ if self.step != "oauth":
333
+ return
334
+ self.query_one("#onboard-error", Static).update(message)
335
+
336
+ def _finish_oauth(self, token: object) -> None:
337
+ if self._oauth_done or self.step != "oauth" or self._pending is None:
338
+ return
339
+ from core_ai.oauth.types import OAuthToken
340
+
341
+ if not isinstance(token, OAuthToken):
342
+ return
343
+ self._oauth_done = True
344
+ save_token(self._pending, token)
345
+ save_provider_auth(self._pending, "oauth")
346
+ self._close_flow()
347
+ self._highlight_continue = True
348
+ self.show_pick()
349
+
350
+ def _close_flow(self) -> None:
351
+ flow = self._flow
352
+ self._flow = None
353
+ if flow is not None:
354
+ flow.close()
355
+
356
+ def _refresh_list(self) -> None:
357
+ configured = configured_provider_ids()
358
+ options = [_provider_row(spec, spec.id in configured) for spec in PROVIDERS]
359
+ if configured:
360
+ options.append(_continue_row(configured))
361
+ listing = self.query_one("#provider-list", OptionList)
362
+ listing.set_options(options)
363
+ if configured and self._highlight_continue:
364
+ listing.highlighted = listing.option_count - 1
365
+ self._highlight_continue = False
366
+ elif listing.option_count:
367
+ listing.highlighted = 0
368
+
369
+ def _hint_text(self) -> str:
370
+ if self.step == "oauth":
371
+ if self._flow is not None and self._flow.prompt.kind == "device":
372
+ return "Approve in the browser Esc back"
373
+ return "Enter paste code Esc back"
374
+ if self.step == "method":
375
+ return "↑↓ select Enter continue Esc back"
376
+ if self.step == "key":
377
+ if self._pending and not get_provider(self._pending).requires_key:
378
+ spec = get_provider(self._pending)
379
+ if spec.default_base_url:
380
+ return "Enter save (blank uses default) Esc back"
381
+ return "Enter save Esc back"
382
+ return "Enter save Esc back"
383
+ close = "skip" if self.first_run else "close"
384
+ if configured_provider_ids():
385
+ return f"↑↓ select Enter add Continue starts Esc {close}"
386
+ return f"↑↓ select Enter add Esc {close}"
387
+
388
+ def _set_hint(self) -> None:
389
+ self.query_one("#onboard-hint", Static).update(self._hint_text())
390
+
391
+
392
+ class OnboardApp(App[tuple[str, ...]]):
393
+ """Full-screen first-run provider setup, matching the resume selector."""
394
+
395
+ CSS = ONBOARD_CSS
396
+ BINDINGS = [
397
+ Binding("escape", "cancel", "skip"),
398
+ Binding("ctrl+c", "cancel", "quit", show=False),
399
+ ]
400
+
401
+ def __init__(self, workspace: str | Path) -> None:
402
+ super().__init__()
403
+ self.workspace = Path(workspace)
404
+
405
+ def compose(self) -> ComposeResult:
406
+ with Container(id="onboard-page"):
407
+ yield ProviderWizard(self.workspace, first_run=True)
408
+
409
+ def on_mount(self) -> None:
410
+ self.console.push_theme(SYMPHONY_RICH_THEME, inherit=True)
411
+ wizard = self.query_one(ProviderWizard)
412
+ if wizard.step == "pick":
413
+ self.query_one("#provider-list", OptionList).focus()
414
+
415
+ def on_provider_wizard_completed(self, event: ProviderWizard.Completed) -> None:
416
+ event.stop()
417
+ self.exit(event.providers)
418
+
419
+ def action_cancel(self) -> None:
420
+ wizard = self.query_one(ProviderWizard)
421
+ if not wizard.cancel():
422
+ return
423
+ self.exit(configured_provider_ids())
424
+
425
+
426
+ class ProviderOnboardScreen(ModalBase[tuple[str, ...] | None]):
427
+ """In-session overlay for `/provider`."""
428
+
429
+ CSS = PROVIDER_MODAL_CSS
430
+
431
+ def __init__(
432
+ self,
433
+ workspace: str | Path,
434
+ *,
435
+ initial_provider: str | None = None,
436
+ ) -> None:
437
+ super().__init__()
438
+ self.workspace = Path(workspace)
439
+ self.initial_provider = initial_provider
440
+
441
+ def compose(self) -> ComposeResult:
442
+ with Container(id="provider-pane", classes="modal-pane"):
443
+ yield ModalCloseButton("Esc", id="modal-close")
444
+ yield ProviderWizard(
445
+ self.workspace,
446
+ initial_provider=self.initial_provider,
447
+ )
448
+
449
+ def on_provider_wizard_completed(self, event: ProviderWizard.Completed) -> None:
450
+ event.stop()
451
+ dismiss_overlay(self, event.providers)
452
+
453
+ def action_close_modal(self) -> None:
454
+ wizard = self.query_one(ProviderWizard)
455
+ wizard.cancel()