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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- 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()
|