pairwit 0.0.2__tar.gz → 0.0.4__tar.gz
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.
- {pairwit-0.0.2 → pairwit-0.0.4}/PKG-INFO +3 -1
- {pairwit-0.0.2 → pairwit-0.0.4}/README.md +2 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/__init__.py +1 -1
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/clients.py +117 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/providers.py +4 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/app.py +67 -10
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/__init__.py +2 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/modals.py +447 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/util/env.py +44 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pyproject.toml +2 -2
- pairwit-0.0.2/site/README.md +0 -106
- {pairwit-0.0.2 → pairwit-0.0.4}/.gitignore +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ARCHITECTURE.md +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/__main__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/analytics.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/context.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/errors.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/events.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/loop.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/stream.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/agent/turn.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/catalog.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/ai/types.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/assets/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/assets/pairwit-mascot-8bit.png +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/core.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/context.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/git.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/paths.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/permissions.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/prompts.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/schedules.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/domain/sessions.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/approval.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/base.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/constants.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/execution.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/mcp.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/modes.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/registry.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/result.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/schemas.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tools/types.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/clipboard.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/theme.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/cards.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/chrome.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/editors.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/explorer.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/formatters.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/input_area.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/schedule.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/tui/widgets/tools_ui.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.4}/pairwit/util/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pairwit
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Consent-first coding agent for your terminal (Plan/Build, approvals, /schedule)
|
|
5
5
|
Project-URL: Homepage, https://pairwit.dev
|
|
6
6
|
Project-URL: Repository, https://github.com/pairwit/pairwit
|
|
@@ -35,6 +35,8 @@ pairwit
|
|
|
35
35
|
|
|
36
36
|
Public alpha — APIs and UX may change. Feedback welcome.
|
|
37
37
|
|
|
38
|
+
On first launch, Pairwit opens a guided provider setup (pick provider → API key → model → connection check). Keys go to `~/.pairwit/.env`. On Windows, if `pairwit` is not on PATH, use `py -m pairwit`.
|
|
39
|
+
|
|
38
40
|
Editable install from a checkout:
|
|
39
41
|
|
|
40
42
|
```bash
|
|
@@ -9,6 +9,8 @@ pairwit
|
|
|
9
9
|
|
|
10
10
|
Public alpha — APIs and UX may change. Feedback welcome.
|
|
11
11
|
|
|
12
|
+
On first launch, Pairwit opens a guided provider setup (pick provider → API key → model → connection check). Keys go to `~/.pairwit/.env`. On Windows, if `pairwit` is not on PATH, use `py -m pairwit`.
|
|
13
|
+
|
|
12
14
|
Editable install from a checkout:
|
|
13
15
|
|
|
14
16
|
```bash
|
|
@@ -112,6 +112,123 @@ def _build_anthropic_client() -> Any:
|
|
|
112
112
|
return _anthropic_mod.Anthropic(api_key=key)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
PROVIDER_KEY_HINTS: dict[str, str] = {
|
|
116
|
+
"moonshot": "Get a key: https://platform.moonshot.ai",
|
|
117
|
+
"openai": "Get a key: https://platform.openai.com/api-keys",
|
|
118
|
+
"anthropic": "Get a key: https://console.anthropic.com/settings/keys",
|
|
119
|
+
"openrouter": "Get a key: https://openrouter.ai/keys",
|
|
120
|
+
"deepseek": "Get a key: https://platform.deepseek.com/api_keys",
|
|
121
|
+
"custom": "Use any OpenAI-compatible base URL + API key.",
|
|
122
|
+
"ollama": "Install Ollama and pull a model: https://ollama.com",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _smoke_test_provider(
|
|
127
|
+
provider_id: str,
|
|
128
|
+
model: str,
|
|
129
|
+
*,
|
|
130
|
+
api_key: str | None = None,
|
|
131
|
+
base_url: str | None = None,
|
|
132
|
+
timeout_s: float = 12.0,
|
|
133
|
+
) -> str | None:
|
|
134
|
+
"""Return None on success, or a short error message if the provider cannot chat.
|
|
135
|
+
|
|
136
|
+
Optional api_key / base_url override process env so the wizard can verify
|
|
137
|
+
before writing ~/.pairwit/.env.
|
|
138
|
+
"""
|
|
139
|
+
from pairwit.ai.catalog import _discover_ollama_models
|
|
140
|
+
|
|
141
|
+
provider_id = (provider_id or "").strip().lower()
|
|
142
|
+
model = (model or "").strip()
|
|
143
|
+
if provider_id not in PROVIDERS:
|
|
144
|
+
return f"Unknown provider: {provider_id}"
|
|
145
|
+
if not model:
|
|
146
|
+
return "Pick a model to continue."
|
|
147
|
+
|
|
148
|
+
if provider_id == "ollama":
|
|
149
|
+
models = _discover_ollama_models()
|
|
150
|
+
if not models:
|
|
151
|
+
return f"Ollama unreachable at {_ollama_host()} — start the daemon and pull a model."
|
|
152
|
+
ids = {m.id for m in models}
|
|
153
|
+
# Allow tag-less matches (llama3.2 vs llama3.2:latest)
|
|
154
|
+
if model not in ids and not any(mid.startswith(f"{model}:") or model.startswith(f"{mid}:") for mid in ids):
|
|
155
|
+
# Still OK if daemon is up — default model may not be pulled yet
|
|
156
|
+
return (
|
|
157
|
+
f"Ollama is up but model `{model}` was not found. "
|
|
158
|
+
f"Pull it (`ollama pull {model}`) or pick another model."
|
|
159
|
+
)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
if provider_id == "anthropic":
|
|
163
|
+
if _anthropic_mod is None:
|
|
164
|
+
return "Install the anthropic package: pip install anthropic"
|
|
165
|
+
if api_key is not None:
|
|
166
|
+
key = api_key.strip()
|
|
167
|
+
else:
|
|
168
|
+
key = (_provider_api_key("anthropic") or "").strip()
|
|
169
|
+
if not key:
|
|
170
|
+
return "ANTHROPIC_API_KEY is required."
|
|
171
|
+
try:
|
|
172
|
+
client = _anthropic_mod.Anthropic(api_key=key, timeout=timeout_s)
|
|
173
|
+
client.messages.create(
|
|
174
|
+
model=model,
|
|
175
|
+
max_tokens=1,
|
|
176
|
+
messages=[{"role": "user", "content": "ping"}],
|
|
177
|
+
)
|
|
178
|
+
except Exception as exc: # noqa: BLE001 — surface provider error to wizard
|
|
179
|
+
return _smoke_error_text(exc)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
# OpenAI-compatible providers
|
|
183
|
+
if api_key is not None:
|
|
184
|
+
key = api_key.strip()
|
|
185
|
+
else:
|
|
186
|
+
key = (_provider_api_key(provider_id) or "").strip()
|
|
187
|
+
if not key:
|
|
188
|
+
env_name = PROVIDERS[provider_id].api_key_env or "API key"
|
|
189
|
+
return f"{env_name} is required."
|
|
190
|
+
|
|
191
|
+
if provider_id == "custom":
|
|
192
|
+
if base_url is not None:
|
|
193
|
+
resolved_base = base_url.strip().rstrip("/")
|
|
194
|
+
else:
|
|
195
|
+
resolved_base = (_env_get("PAIRWIT_BASE_URL") or "").rstrip("/")
|
|
196
|
+
if not resolved_base:
|
|
197
|
+
return "PAIRWIT_BASE_URL is required for custom providers."
|
|
198
|
+
else:
|
|
199
|
+
resolved_base = _openai_compat_base_url(provider_id)
|
|
200
|
+
|
|
201
|
+
headers: dict[str, str] | None = None
|
|
202
|
+
if provider_id == "openrouter":
|
|
203
|
+
headers = {
|
|
204
|
+
"HTTP-Referer": "https://github.com/pairwit/pairwit",
|
|
205
|
+
"X-Title": "Pairwit",
|
|
206
|
+
}
|
|
207
|
+
try:
|
|
208
|
+
client = OpenAI(
|
|
209
|
+
api_key=key or "ollama",
|
|
210
|
+
base_url=resolved_base,
|
|
211
|
+
default_headers=headers,
|
|
212
|
+
timeout=timeout_s,
|
|
213
|
+
)
|
|
214
|
+
client.chat.completions.create(
|
|
215
|
+
model=model,
|
|
216
|
+
messages=[{"role": "user", "content": "ping"}],
|
|
217
|
+
max_tokens=1,
|
|
218
|
+
)
|
|
219
|
+
except Exception as exc: # noqa: BLE001
|
|
220
|
+
return _smoke_error_text(exc)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _smoke_error_text(exc: BaseException) -> str:
|
|
225
|
+
text = str(exc).strip() or exc.__class__.__name__
|
|
226
|
+
# Keep the modal readable
|
|
227
|
+
if len(text) > 180:
|
|
228
|
+
text = text[:177] + "…"
|
|
229
|
+
return text
|
|
230
|
+
|
|
231
|
+
|
|
115
232
|
def _openai_tools_to_anthropic(tools: list[dict]) -> list[dict]:
|
|
116
233
|
converted: list[dict] = []
|
|
117
234
|
for tool in tools:
|
|
@@ -22,6 +22,7 @@ from pairwit.ai.catalog import (
|
|
|
22
22
|
_set_active_pricing,
|
|
23
23
|
)
|
|
24
24
|
from pairwit.ai.clients import (
|
|
25
|
+
PROVIDER_KEY_HINTS,
|
|
25
26
|
_anthropic_mod,
|
|
26
27
|
_build_anthropic_client,
|
|
27
28
|
_build_openai_client,
|
|
@@ -33,6 +34,7 @@ from pairwit.ai.clients import (
|
|
|
33
34
|
_openai_tools_to_anthropic,
|
|
34
35
|
_provider_api_key,
|
|
35
36
|
_provider_missing_key_message,
|
|
37
|
+
_smoke_test_provider,
|
|
36
38
|
)
|
|
37
39
|
from pairwit.ai.types import ModelInfo, ProviderSpec
|
|
38
40
|
|
|
@@ -44,6 +46,7 @@ __all__ = [
|
|
|
44
46
|
"MODEL_INPUT_USD_PER_M",
|
|
45
47
|
"MODEL_OUTPUT_USD_PER_M",
|
|
46
48
|
"PROVIDER",
|
|
49
|
+
"PROVIDER_KEY_HINTS",
|
|
47
50
|
"PROVIDER_ORDER",
|
|
48
51
|
"PROVIDERS",
|
|
49
52
|
"ModelInfo",
|
|
@@ -67,4 +70,5 @@ __all__ = [
|
|
|
67
70
|
"_provider_missing_key_message",
|
|
68
71
|
"_provider_spec",
|
|
69
72
|
"_set_active_pricing",
|
|
73
|
+
"_smoke_test_provider",
|
|
70
74
|
]
|
|
@@ -164,6 +164,7 @@ from pairwit.tui.widgets import (
|
|
|
164
164
|
Prompt,
|
|
165
165
|
PromptModal,
|
|
166
166
|
ProviderPickerScreen,
|
|
167
|
+
ProviderSetupScreen,
|
|
167
168
|
Response,
|
|
168
169
|
SchedulePane,
|
|
169
170
|
SessionStatsBar,
|
|
@@ -523,7 +524,9 @@ class PairwitApp(App):
|
|
|
523
524
|
self._openai_client: OpenAI | None = None
|
|
524
525
|
self._anthropic_client: Any | None = None
|
|
525
526
|
self._provider_note: str | None = None
|
|
526
|
-
self.
|
|
527
|
+
self._needs_provider_setup = bool(_provider_missing_key_message(self._provider))
|
|
528
|
+
if not self._needs_provider_setup:
|
|
529
|
+
self.rebuild_llm()
|
|
527
530
|
self._refresh_mode_subtitle()
|
|
528
531
|
|
|
529
532
|
def rebuild_llm(self) -> None:
|
|
@@ -710,6 +713,8 @@ class PairwitApp(App):
|
|
|
710
713
|
self.call_after_refresh(self._show_startup_note, self._provider_note)
|
|
711
714
|
if self._mcp_note:
|
|
712
715
|
self.call_after_refresh(self._show_startup_note, self._mcp_note)
|
|
716
|
+
if self._needs_provider_setup:
|
|
717
|
+
self.call_after_refresh(self._launch_provider_setup)
|
|
713
718
|
if self._pending_replay_banner:
|
|
714
719
|
banner = self._pending_replay_banner
|
|
715
720
|
self._pending_replay_banner = None
|
|
@@ -722,6 +727,46 @@ class PairwitApp(App):
|
|
|
722
727
|
def _show_startup_note(self, text: str) -> None:
|
|
723
728
|
self.run_worker(self._show_system_note(text))
|
|
724
729
|
|
|
730
|
+
def _launch_provider_setup(self) -> None:
|
|
731
|
+
self._run_provider_setup()
|
|
732
|
+
|
|
733
|
+
@work(exclusive=True)
|
|
734
|
+
async def _run_provider_setup(self) -> None:
|
|
735
|
+
"""Block first-run until a usable provider is configured."""
|
|
736
|
+
reason = _provider_missing_key_message(self._provider)
|
|
737
|
+
while self._needs_provider_setup:
|
|
738
|
+
result = await self.push_screen_wait(
|
|
739
|
+
ProviderSetupScreen(
|
|
740
|
+
current_provider=self._provider,
|
|
741
|
+
reason=(reason or "").replace("❌ ", "").split("\n", 1)[0] or None,
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
if result is None:
|
|
745
|
+
self.exit(return_code=1)
|
|
746
|
+
return
|
|
747
|
+
self._provider = result["provider"]
|
|
748
|
+
self._model = result.get("model") or PROVIDERS[self._provider].default_model
|
|
749
|
+
try:
|
|
750
|
+
self.rebuild_llm()
|
|
751
|
+
except Exception as exc:
|
|
752
|
+
reason = str(exc)
|
|
753
|
+
await self._show_system_note(f"Could not start provider: {exc}")
|
|
754
|
+
continue
|
|
755
|
+
if _provider_missing_key_message(self._provider):
|
|
756
|
+
reason = _provider_missing_key_message(self._provider)
|
|
757
|
+
continue
|
|
758
|
+
self._needs_provider_setup = False
|
|
759
|
+
self._refresh_mode_subtitle()
|
|
760
|
+
env_path = result.get("env_path", "~/.pairwit/.env")
|
|
761
|
+
note = (
|
|
762
|
+
f"Connected · `{self._model}` · `{self._provider}`.\n"
|
|
763
|
+
f"Saved to `{env_path}`. Change later with /provider."
|
|
764
|
+
)
|
|
765
|
+
if self._provider_note:
|
|
766
|
+
note = f"{note}\n{self._provider_note}"
|
|
767
|
+
await self._show_system_note(note)
|
|
768
|
+
return
|
|
769
|
+
|
|
725
770
|
def _startup_replay(self, banner: str) -> None:
|
|
726
771
|
self.run_worker(self._replay_session_ui(banner=banner))
|
|
727
772
|
|
|
@@ -1601,8 +1646,17 @@ class PairwitApp(App):
|
|
|
1601
1646
|
return
|
|
1602
1647
|
fatal = _provider_missing_key_message(picked)
|
|
1603
1648
|
if fatal:
|
|
1604
|
-
await self.
|
|
1605
|
-
|
|
1649
|
+
setup = await self.push_screen_wait(
|
|
1650
|
+
ProviderSetupScreen(
|
|
1651
|
+
current_provider=self._provider,
|
|
1652
|
+
initial_provider=picked,
|
|
1653
|
+
reason=f"Configure {picked} before switching.",
|
|
1654
|
+
)
|
|
1655
|
+
)
|
|
1656
|
+
if not setup:
|
|
1657
|
+
return
|
|
1658
|
+
picked = setup["provider"]
|
|
1659
|
+
self._model = setup.get("model") or PROVIDERS[picked].default_model
|
|
1606
1660
|
prev_provider = self._provider
|
|
1607
1661
|
prev_model = self._model
|
|
1608
1662
|
self._provider = picked
|
|
@@ -1615,11 +1669,13 @@ class PairwitApp(App):
|
|
|
1615
1669
|
self._provider = prev_provider
|
|
1616
1670
|
self._model = prev_model
|
|
1617
1671
|
try:
|
|
1618
|
-
self.
|
|
1672
|
+
if not self._needs_provider_setup:
|
|
1673
|
+
self.rebuild_llm()
|
|
1619
1674
|
except Exception:
|
|
1620
1675
|
pass
|
|
1621
1676
|
await self._show_system_note(f"Could not switch provider: {exc}")
|
|
1622
1677
|
return
|
|
1678
|
+
self._needs_provider_setup = False
|
|
1623
1679
|
self._refresh_mode_subtitle()
|
|
1624
1680
|
note = f"Provider set to `{self._provider}` · model `{self._model}`."
|
|
1625
1681
|
if self._provider_note:
|
|
@@ -1710,6 +1766,13 @@ class PairwitApp(App):
|
|
|
1710
1766
|
|
|
1711
1767
|
@on(Input.Submitted, "#prompt-input")
|
|
1712
1768
|
async def on_input(self, event: Input.Submitted) -> None:
|
|
1769
|
+
if self._needs_provider_setup:
|
|
1770
|
+
event.input.clear()
|
|
1771
|
+
await self._show_system_note(
|
|
1772
|
+
"Finish provider setup first (or press Esc in the setup dialog to quit)."
|
|
1773
|
+
)
|
|
1774
|
+
self._launch_provider_setup()
|
|
1775
|
+
return
|
|
1713
1776
|
raw = event.value.strip()
|
|
1714
1777
|
if raw.startswith("/"):
|
|
1715
1778
|
event.input.clear()
|
|
@@ -2294,12 +2357,6 @@ class PairwitApp(App):
|
|
|
2294
2357
|
|
|
2295
2358
|
def agent_loop() -> None:
|
|
2296
2359
|
resume = "--resume" in sys.argv[1:]
|
|
2297
|
-
provider = (_env_get("PAIRWIT_PROVIDER") or DEFAULT_PROVIDER).strip().lower()
|
|
2298
|
-
if provider not in PROVIDERS:
|
|
2299
|
-
provider = DEFAULT_PROVIDER
|
|
2300
|
-
fatal = _provider_missing_key_message(provider)
|
|
2301
|
-
if fatal:
|
|
2302
|
-
raise SystemExit(fatal)
|
|
2303
2360
|
PairwitApp(resume=resume).run()
|
|
2304
2361
|
|
|
2305
2362
|
|
|
@@ -42,6 +42,7 @@ from pairwit.tui.widgets.modals import (
|
|
|
42
42
|
ModelPickerScreen,
|
|
43
43
|
PromptModal,
|
|
44
44
|
ProviderPickerScreen,
|
|
45
|
+
ProviderSetupScreen,
|
|
45
46
|
SessionsPickerScreen,
|
|
46
47
|
ToolApprovalScreen,
|
|
47
48
|
TreeContextMenu,
|
|
@@ -88,6 +89,7 @@ __all__ = [
|
|
|
88
89
|
"Prompt",
|
|
89
90
|
"PromptModal",
|
|
90
91
|
"ProviderPickerScreen",
|
|
92
|
+
"ProviderSetupScreen",
|
|
91
93
|
"Response",
|
|
92
94
|
"SCHEDULE_DEFAULT_WIDTH",
|
|
93
95
|
"SCHEDULE_MIN_WIDTH",
|
|
@@ -14,11 +14,15 @@ from textual.widgets import Input, OptionList, Static
|
|
|
14
14
|
from textual.widgets._option_list import Option
|
|
15
15
|
|
|
16
16
|
from pairwit.ai.providers import (
|
|
17
|
+
PROVIDER_KEY_HINTS,
|
|
17
18
|
PROVIDER_ORDER,
|
|
18
19
|
PROVIDERS,
|
|
19
20
|
ModelInfo,
|
|
20
21
|
_discover_ollama_models,
|
|
22
|
+
_models_for_provider,
|
|
23
|
+
_ollama_host,
|
|
21
24
|
_provider_missing_key_message,
|
|
25
|
+
_smoke_test_provider,
|
|
22
26
|
)
|
|
23
27
|
from pairwit.core import MENTION_PICKER_MAX, MODEL
|
|
24
28
|
from pairwit.tools.approval import (
|
|
@@ -761,3 +765,446 @@ class TreeContextMenu(ModalScreen[Optional[str]]):
|
|
|
761
765
|
def on_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
762
766
|
event.stop()
|
|
763
767
|
self.dismiss(event.option_id)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class ProviderSetupScreen(ModalScreen[dict[str, str] | None]):
|
|
771
|
+
"""Hermes-style first-run wizard: welcome → provider → key → model → verify."""
|
|
772
|
+
|
|
773
|
+
BINDINGS = [
|
|
774
|
+
Binding("escape", "cancel", "Back", show=False),
|
|
775
|
+
]
|
|
776
|
+
|
|
777
|
+
STEPS = ("welcome", "provider", "key", "model", "verify")
|
|
778
|
+
|
|
779
|
+
DEFAULT_CSS = f"""
|
|
780
|
+
ProviderSetupScreen {{
|
|
781
|
+
align: center middle;
|
|
782
|
+
background: #091e42 78%;
|
|
783
|
+
}}
|
|
784
|
+
|
|
785
|
+
ProviderSetupScreen #setup-modal-box {{
|
|
786
|
+
width: 74;
|
|
787
|
+
height: auto;
|
|
788
|
+
max-height: 28;
|
|
789
|
+
border: tall {KIMI_BLUE};
|
|
790
|
+
background: {KIMI_SURFACE};
|
|
791
|
+
padding: 1 2;
|
|
792
|
+
}}
|
|
793
|
+
|
|
794
|
+
ProviderSetupScreen #setup-title {{
|
|
795
|
+
color: {KIMI_TEXT};
|
|
796
|
+
text-style: bold;
|
|
797
|
+
padding: 0 0 0 0;
|
|
798
|
+
}}
|
|
799
|
+
|
|
800
|
+
ProviderSetupScreen #setup-step {{
|
|
801
|
+
color: {KIMI_MUTED};
|
|
802
|
+
padding: 0 0 1 0;
|
|
803
|
+
}}
|
|
804
|
+
|
|
805
|
+
ProviderSetupScreen #setup-hint {{
|
|
806
|
+
color: {KIMI_MUTED};
|
|
807
|
+
padding: 0 0 1 0;
|
|
808
|
+
}}
|
|
809
|
+
|
|
810
|
+
ProviderSetupScreen #setup-error {{
|
|
811
|
+
color: {KIMI_ORANGE};
|
|
812
|
+
padding: 0 0 1 0;
|
|
813
|
+
}}
|
|
814
|
+
|
|
815
|
+
ProviderSetupScreen #setup-status {{
|
|
816
|
+
color: {KIMI_TEXT};
|
|
817
|
+
padding: 0 0 1 0;
|
|
818
|
+
}}
|
|
819
|
+
|
|
820
|
+
ProviderSetupScreen OptionList {{
|
|
821
|
+
height: auto;
|
|
822
|
+
max-height: 12;
|
|
823
|
+
border: solid {KIMI_BORDER};
|
|
824
|
+
}}
|
|
825
|
+
|
|
826
|
+
ProviderSetupScreen .option-list--option-highlighted {{
|
|
827
|
+
background: {KIMI_BLUE} 18%;
|
|
828
|
+
color: {KIMI_TEXT};
|
|
829
|
+
}}
|
|
830
|
+
|
|
831
|
+
ProviderSetupScreen Input {{
|
|
832
|
+
border: solid {KIMI_BORDER};
|
|
833
|
+
background: {KIMI_SURFACE_ALT};
|
|
834
|
+
color: {KIMI_TEXT};
|
|
835
|
+
margin: 0 0 1 0;
|
|
836
|
+
}}
|
|
837
|
+
|
|
838
|
+
ProviderSetupScreen #setup-footer {{
|
|
839
|
+
color: {KIMI_MUTED};
|
|
840
|
+
padding: 1 0 0 0;
|
|
841
|
+
}}
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
def __init__(
|
|
845
|
+
self,
|
|
846
|
+
*,
|
|
847
|
+
current_provider: str = "moonshot",
|
|
848
|
+
reason: str | None = None,
|
|
849
|
+
initial_provider: str | None = None,
|
|
850
|
+
) -> None:
|
|
851
|
+
super().__init__()
|
|
852
|
+
self._current_provider = current_provider
|
|
853
|
+
self._reason = reason
|
|
854
|
+
self._providers = [PROVIDERS[pid] for pid in PROVIDER_ORDER if pid in PROVIDERS]
|
|
855
|
+
self._step = "welcome"
|
|
856
|
+
self._selected_id = initial_provider if initial_provider in PROVIDERS else None
|
|
857
|
+
self._api_key = ""
|
|
858
|
+
self._base_url = ""
|
|
859
|
+
self._model = (
|
|
860
|
+
PROVIDERS[self._selected_id].default_model
|
|
861
|
+
if self._selected_id
|
|
862
|
+
else PROVIDERS.get(current_provider, PROVIDERS["moonshot"]).default_model
|
|
863
|
+
)
|
|
864
|
+
self._verifying = False
|
|
865
|
+
self._verify_error = ""
|
|
866
|
+
|
|
867
|
+
def compose(self) -> ComposeResult:
|
|
868
|
+
with Vertical(id="setup-modal-box"):
|
|
869
|
+
yield Static("Welcome to Pairwit", id="setup-title")
|
|
870
|
+
yield Static("Step 1 of 5", id="setup-step")
|
|
871
|
+
yield Static("", id="setup-hint")
|
|
872
|
+
yield Static("", id="setup-error")
|
|
873
|
+
yield Static("", id="setup-status")
|
|
874
|
+
yield OptionList(id="setup-list")
|
|
875
|
+
yield Static("", id="setup-key-label")
|
|
876
|
+
yield Input(placeholder="API key", password=True, id="setup-api-key")
|
|
877
|
+
yield Static("", id="setup-base-label")
|
|
878
|
+
yield Input(placeholder="https://…/v1", id="setup-base-url")
|
|
879
|
+
yield Static("Enter · continue Esc · back / quit", id="setup-footer")
|
|
880
|
+
|
|
881
|
+
def on_mount(self) -> None:
|
|
882
|
+
if self._selected_id:
|
|
883
|
+
# Jump into credentials (or model for ollama) when opened from /provider.
|
|
884
|
+
if self._selected_id == "ollama":
|
|
885
|
+
self._show_step("model")
|
|
886
|
+
else:
|
|
887
|
+
self._show_step("key")
|
|
888
|
+
return
|
|
889
|
+
self._show_step("welcome")
|
|
890
|
+
|
|
891
|
+
def _set_error(self, message: str) -> None:
|
|
892
|
+
self.query_one("#setup-error", Static).update(message)
|
|
893
|
+
|
|
894
|
+
def _set_status(self, message: str) -> None:
|
|
895
|
+
self.query_one("#setup-status", Static).update(message)
|
|
896
|
+
|
|
897
|
+
def _hide_inputs(self) -> None:
|
|
898
|
+
self.query_one("#setup-key-label", Static).display = False
|
|
899
|
+
self.query_one("#setup-api-key", Input).display = False
|
|
900
|
+
self.query_one("#setup-base-label", Static).display = False
|
|
901
|
+
self.query_one("#setup-base-url", Input).display = False
|
|
902
|
+
|
|
903
|
+
def _step_index(self) -> int:
|
|
904
|
+
try:
|
|
905
|
+
return self.STEPS.index(self._step) + 1
|
|
906
|
+
except ValueError:
|
|
907
|
+
return 1
|
|
908
|
+
|
|
909
|
+
def _show_step(self, step: str) -> None:
|
|
910
|
+
self._step = step
|
|
911
|
+
self._set_error("")
|
|
912
|
+
self._set_status("")
|
|
913
|
+
self._hide_inputs()
|
|
914
|
+
title = self.query_one("#setup-title", Static)
|
|
915
|
+
step_label = self.query_one("#setup-step", Static)
|
|
916
|
+
hint = self.query_one("#setup-hint", Static)
|
|
917
|
+
option_list = self.query_one("#setup-list", OptionList)
|
|
918
|
+
footer = self.query_one("#setup-footer", Static)
|
|
919
|
+
step_label.update(f"Step {self._step_index()} of {len(self.STEPS)}")
|
|
920
|
+
option_list.display = True
|
|
921
|
+
option_list.clear_options()
|
|
922
|
+
|
|
923
|
+
if step == "welcome":
|
|
924
|
+
title.update("Welcome to Pairwit")
|
|
925
|
+
body = (
|
|
926
|
+
"Pairwit needs one AI provider before it can chat.\n"
|
|
927
|
+
"• Keys are saved locally to ~/.pairwit/.env\n"
|
|
928
|
+
"• Change later anytime with /provider\n"
|
|
929
|
+
"• Approvals stay on by default"
|
|
930
|
+
)
|
|
931
|
+
if self._reason:
|
|
932
|
+
body = f"{self._reason.strip()}\n\n{body}"
|
|
933
|
+
hint.update(body)
|
|
934
|
+
option_list.add_options([Option("Continue — choose a provider", id="act-continue")])
|
|
935
|
+
footer.update("Enter · continue Esc · quit")
|
|
936
|
+
option_list.focus()
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
if step == "provider":
|
|
940
|
+
title.update("Choose a provider")
|
|
941
|
+
hint.update("Moonshot is the recommended path. Ollama needs no API key.")
|
|
942
|
+
options: list[Option] = []
|
|
943
|
+
for index, spec in enumerate(self._providers):
|
|
944
|
+
if spec.id == "moonshot":
|
|
945
|
+
label = f"{spec.label} (recommended) · {spec.api_key_env}"
|
|
946
|
+
elif spec.id == "ollama":
|
|
947
|
+
label = f"{spec.label} · local, no API key"
|
|
948
|
+
elif spec.id == "custom":
|
|
949
|
+
label = f"{spec.label} · key + base URL"
|
|
950
|
+
else:
|
|
951
|
+
label = f"{spec.label} · {spec.api_key_env}"
|
|
952
|
+
if spec.id == self._current_provider:
|
|
953
|
+
label = f"{label} ← current"
|
|
954
|
+
options.append(Option(label, id=f"prov-{index}"))
|
|
955
|
+
option_list.add_options(options)
|
|
956
|
+
footer.update("Enter · select Esc · back")
|
|
957
|
+
option_list.focus()
|
|
958
|
+
return
|
|
959
|
+
|
|
960
|
+
if step == "key":
|
|
961
|
+
assert self._selected_id
|
|
962
|
+
spec = PROVIDERS[self._selected_id]
|
|
963
|
+
title.update(f"Add your {spec.label} key")
|
|
964
|
+
hint_line = PROVIDER_KEY_HINTS.get(spec.id, "")
|
|
965
|
+
hint.update(
|
|
966
|
+
f"Paste {spec.api_key_env} below. {hint_line}".strip()
|
|
967
|
+
)
|
|
968
|
+
option_list.display = False
|
|
969
|
+
key_label = self.query_one("#setup-key-label", Static)
|
|
970
|
+
key_input = self.query_one("#setup-api-key", Input)
|
|
971
|
+
key_label.display = True
|
|
972
|
+
key_input.display = True
|
|
973
|
+
key_label.update(spec.api_key_env or "API key")
|
|
974
|
+
key_input.value = self._api_key
|
|
975
|
+
key_input.placeholder = spec.api_key_env or "API key"
|
|
976
|
+
if spec.id == "custom":
|
|
977
|
+
base_label = self.query_one("#setup-base-label", Static)
|
|
978
|
+
base_input = self.query_one("#setup-base-url", Input)
|
|
979
|
+
base_label.display = True
|
|
980
|
+
base_input.display = True
|
|
981
|
+
base_label.update("PAIRWIT_BASE_URL (OpenAI-compatible)")
|
|
982
|
+
base_input.value = self._base_url
|
|
983
|
+
base_input.focus()
|
|
984
|
+
else:
|
|
985
|
+
key_input.focus()
|
|
986
|
+
footer.update("Enter · continue Esc · back")
|
|
987
|
+
return
|
|
988
|
+
|
|
989
|
+
if step == "model":
|
|
990
|
+
assert self._selected_id
|
|
991
|
+
spec = PROVIDERS[self._selected_id]
|
|
992
|
+
if not self._model:
|
|
993
|
+
self._model = spec.default_model
|
|
994
|
+
title.update("Confirm model")
|
|
995
|
+
hint.update(
|
|
996
|
+
f"Default for {spec.label} is `{spec.default_model}`. "
|
|
997
|
+
"Accept it, or pick another."
|
|
998
|
+
)
|
|
999
|
+
models = _models_for_provider(self._selected_id)
|
|
1000
|
+
options = [
|
|
1001
|
+
Option(
|
|
1002
|
+
f"Accept default · {spec.default_model}",
|
|
1003
|
+
id="model-default",
|
|
1004
|
+
)
|
|
1005
|
+
]
|
|
1006
|
+
for index, info in enumerate(models[:12]):
|
|
1007
|
+
mark = " ✓" if info.id == self._model else ""
|
|
1008
|
+
options.append(
|
|
1009
|
+
Option(f"{info.label} ({info.id}){mark}", id=f"model-{index}")
|
|
1010
|
+
)
|
|
1011
|
+
option_list.add_options(options)
|
|
1012
|
+
footer.update("Enter · select Esc · back")
|
|
1013
|
+
option_list.focus()
|
|
1014
|
+
return
|
|
1015
|
+
|
|
1016
|
+
if step == "verify":
|
|
1017
|
+
assert self._selected_id
|
|
1018
|
+
title.update("Verify connection")
|
|
1019
|
+
hint.update(
|
|
1020
|
+
f"Testing `{self._model}` on {PROVIDERS[self._selected_id].label}…"
|
|
1021
|
+
)
|
|
1022
|
+
option_list.display = False
|
|
1023
|
+
footer.update("Please wait…")
|
|
1024
|
+
self._start_verify()
|
|
1025
|
+
return
|
|
1026
|
+
|
|
1027
|
+
def _start_verify(self) -> None:
|
|
1028
|
+
if self._verifying:
|
|
1029
|
+
return
|
|
1030
|
+
self._verifying = True
|
|
1031
|
+
self._set_status("Checking provider…")
|
|
1032
|
+
self._set_error("")
|
|
1033
|
+
self.run_worker(self._verify_worker, thread=True)
|
|
1034
|
+
|
|
1035
|
+
def _verify_worker(self) -> None:
|
|
1036
|
+
provider_id = self._selected_id or ""
|
|
1037
|
+
error = _smoke_test_provider(
|
|
1038
|
+
provider_id,
|
|
1039
|
+
self._model,
|
|
1040
|
+
api_key=self._api_key or None,
|
|
1041
|
+
base_url=self._base_url or None,
|
|
1042
|
+
)
|
|
1043
|
+
self.app.call_from_thread(self._on_verify_done, error)
|
|
1044
|
+
|
|
1045
|
+
def _on_verify_done(self, error: str | None) -> None:
|
|
1046
|
+
self._verifying = False
|
|
1047
|
+
if error:
|
|
1048
|
+
self._verify_error = error
|
|
1049
|
+
self._set_status("")
|
|
1050
|
+
self._set_error(error)
|
|
1051
|
+
option_list = self.query_one("#setup-list", OptionList)
|
|
1052
|
+
option_list.display = True
|
|
1053
|
+
option_list.clear_options()
|
|
1054
|
+
options = [
|
|
1055
|
+
Option("Retry connection test", id="act-retry"),
|
|
1056
|
+
]
|
|
1057
|
+
if self._selected_id != "ollama":
|
|
1058
|
+
options.append(Option("Change API key", id="act-change-key"))
|
|
1059
|
+
options.append(Option("Choose another provider", id="act-change-provider"))
|
|
1060
|
+
option_list.add_options(options)
|
|
1061
|
+
self.query_one("#setup-footer", Static).update("Enter · choose Esc · back")
|
|
1062
|
+
option_list.focus()
|
|
1063
|
+
return
|
|
1064
|
+
self._finish_success()
|
|
1065
|
+
|
|
1066
|
+
def _finish_success(self) -> None:
|
|
1067
|
+
from pairwit.util.env import upsert_pairwit_env
|
|
1068
|
+
|
|
1069
|
+
provider_id = self._selected_id
|
|
1070
|
+
if not provider_id:
|
|
1071
|
+
self._set_error("No provider selected.")
|
|
1072
|
+
return
|
|
1073
|
+
spec = PROVIDERS[provider_id]
|
|
1074
|
+
values: dict[str, str] = {
|
|
1075
|
+
"PAIRWIT_PROVIDER": provider_id,
|
|
1076
|
+
"PAIRWIT_MODEL": self._model or spec.default_model,
|
|
1077
|
+
}
|
|
1078
|
+
if provider_id != "ollama" and spec.api_key_env and self._api_key:
|
|
1079
|
+
values[spec.api_key_env] = self._api_key
|
|
1080
|
+
if provider_id == "custom" and self._base_url:
|
|
1081
|
+
values["PAIRWIT_BASE_URL"] = self._base_url.rstrip("/")
|
|
1082
|
+
path = upsert_pairwit_env(values)
|
|
1083
|
+
self._set_status(f"Connected · {self._model} · {spec.label}")
|
|
1084
|
+
self.dismiss(
|
|
1085
|
+
{
|
|
1086
|
+
"provider": provider_id,
|
|
1087
|
+
"model": self._model or spec.default_model,
|
|
1088
|
+
"env_path": str(path),
|
|
1089
|
+
}
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
def _advance_from_key(self) -> None:
|
|
1093
|
+
provider_id = self._selected_id
|
|
1094
|
+
if not provider_id:
|
|
1095
|
+
self._set_error("Pick a provider first.")
|
|
1096
|
+
return
|
|
1097
|
+
spec = PROVIDERS[provider_id]
|
|
1098
|
+
key = self.query_one("#setup-api-key", Input).value.strip()
|
|
1099
|
+
if not key:
|
|
1100
|
+
self._set_error(f"Enter {spec.api_key_env} to continue.")
|
|
1101
|
+
self.query_one("#setup-api-key", Input).focus()
|
|
1102
|
+
return
|
|
1103
|
+
self._api_key = key
|
|
1104
|
+
if provider_id == "custom":
|
|
1105
|
+
base = self.query_one("#setup-base-url", Input).value.strip().rstrip("/")
|
|
1106
|
+
if not base:
|
|
1107
|
+
self._set_error("Enter PAIRWIT_BASE_URL (e.g. https://api.example.com/v1).")
|
|
1108
|
+
self.query_one("#setup-base-url", Input).focus()
|
|
1109
|
+
return
|
|
1110
|
+
self._base_url = base
|
|
1111
|
+
if provider_id == "anthropic":
|
|
1112
|
+
from pairwit.ai.clients import _anthropic_mod
|
|
1113
|
+
|
|
1114
|
+
if _anthropic_mod is None:
|
|
1115
|
+
self._set_error("Install the anthropic package: pip install anthropic")
|
|
1116
|
+
return
|
|
1117
|
+
self._model = spec.default_model
|
|
1118
|
+
self._show_step("model")
|
|
1119
|
+
|
|
1120
|
+
def action_cancel(self) -> None:
|
|
1121
|
+
if self._verifying:
|
|
1122
|
+
return
|
|
1123
|
+
if self._step == "welcome":
|
|
1124
|
+
self.dismiss(None)
|
|
1125
|
+
return
|
|
1126
|
+
if self._step == "provider":
|
|
1127
|
+
self._show_step("welcome")
|
|
1128
|
+
return
|
|
1129
|
+
if self._step == "key":
|
|
1130
|
+
self._show_step("provider")
|
|
1131
|
+
return
|
|
1132
|
+
if self._step == "model":
|
|
1133
|
+
if self._selected_id == "ollama":
|
|
1134
|
+
self._show_step("provider")
|
|
1135
|
+
else:
|
|
1136
|
+
self._show_step("key")
|
|
1137
|
+
return
|
|
1138
|
+
if self._step == "verify":
|
|
1139
|
+
self._show_step("model")
|
|
1140
|
+
return
|
|
1141
|
+
self.dismiss(None)
|
|
1142
|
+
|
|
1143
|
+
@on(OptionList.OptionSelected, "#setup-list")
|
|
1144
|
+
def on_list_selected(self, event: OptionList.OptionSelected) -> None:
|
|
1145
|
+
event.stop()
|
|
1146
|
+
option_id = event.option_id or ""
|
|
1147
|
+
if option_id == "act-continue":
|
|
1148
|
+
self._show_step("provider")
|
|
1149
|
+
return
|
|
1150
|
+
if option_id == "act-retry":
|
|
1151
|
+
self._show_step("verify")
|
|
1152
|
+
return
|
|
1153
|
+
if option_id == "act-change-key":
|
|
1154
|
+
self._show_step("key")
|
|
1155
|
+
return
|
|
1156
|
+
if option_id == "act-change-provider":
|
|
1157
|
+
self._show_step("provider")
|
|
1158
|
+
return
|
|
1159
|
+
if option_id.startswith("prov-"):
|
|
1160
|
+
try:
|
|
1161
|
+
index = int(option_id.split("-", 1)[1])
|
|
1162
|
+
except ValueError:
|
|
1163
|
+
return
|
|
1164
|
+
if index < 0 or index >= len(self._providers):
|
|
1165
|
+
return
|
|
1166
|
+
self._selected_id = self._providers[index].id
|
|
1167
|
+
self._model = self._providers[index].default_model
|
|
1168
|
+
self._api_key = ""
|
|
1169
|
+
self._base_url = ""
|
|
1170
|
+
if self._selected_id == "ollama":
|
|
1171
|
+
host = _ollama_host()
|
|
1172
|
+
if not _discover_ollama_models():
|
|
1173
|
+
self._set_error(
|
|
1174
|
+
f"Ollama unreachable at {host}. Start it, then pick Ollama again."
|
|
1175
|
+
)
|
|
1176
|
+
return
|
|
1177
|
+
self._show_step("model")
|
|
1178
|
+
else:
|
|
1179
|
+
self._show_step("key")
|
|
1180
|
+
return
|
|
1181
|
+
if option_id == "model-default":
|
|
1182
|
+
assert self._selected_id
|
|
1183
|
+
self._model = PROVIDERS[self._selected_id].default_model
|
|
1184
|
+
self._show_step("verify")
|
|
1185
|
+
return
|
|
1186
|
+
if option_id.startswith("model-"):
|
|
1187
|
+
try:
|
|
1188
|
+
index = int(option_id.split("-", 1)[1])
|
|
1189
|
+
except ValueError:
|
|
1190
|
+
return
|
|
1191
|
+
models = _models_for_provider(self._selected_id or "")
|
|
1192
|
+
if index < 0 or index >= len(models):
|
|
1193
|
+
return
|
|
1194
|
+
self._model = models[index].id
|
|
1195
|
+
self._show_step("verify")
|
|
1196
|
+
return
|
|
1197
|
+
|
|
1198
|
+
@on(Input.Submitted, "#setup-api-key")
|
|
1199
|
+
def on_api_key_submitted(self, event: Input.Submitted) -> None:
|
|
1200
|
+
event.stop()
|
|
1201
|
+
self._advance_from_key()
|
|
1202
|
+
|
|
1203
|
+
@on(Input.Submitted, "#setup-base-url")
|
|
1204
|
+
def on_base_url_submitted(self, event: Input.Submitted) -> None:
|
|
1205
|
+
event.stop()
|
|
1206
|
+
key = self.query_one("#setup-api-key", Input)
|
|
1207
|
+
if not key.value.strip():
|
|
1208
|
+
key.focus()
|
|
1209
|
+
return
|
|
1210
|
+
self._advance_from_key()
|
|
@@ -53,3 +53,47 @@ def _env_flag(name: str, *, default: bool = True) -> bool:
|
|
|
53
53
|
if value is None:
|
|
54
54
|
return default
|
|
55
55
|
return value.strip().lower() not in ("0", "false", "no", "off")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def upsert_pairwit_env(values: dict[str, str]) -> Path:
|
|
59
|
+
"""Write/update keys in `~/.pairwit/.env` and apply them to the process env.
|
|
60
|
+
|
|
61
|
+
Preserves unrelated lines and comments. Creates the home dir if needed.
|
|
62
|
+
"""
|
|
63
|
+
path = Path.home() / ".pairwit" / ".env"
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
cleaned = {key: value.strip() for key, value in values.items() if value and value.strip()}
|
|
67
|
+
if not cleaned:
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
existing_lines: list[str] = []
|
|
71
|
+
if path.is_file():
|
|
72
|
+
existing_lines = path.read_text(encoding="utf-8").splitlines()
|
|
73
|
+
|
|
74
|
+
seen: set[str] = set()
|
|
75
|
+
out: list[str] = []
|
|
76
|
+
for line in existing_lines:
|
|
77
|
+
stripped = line.strip()
|
|
78
|
+
if not stripped or stripped.startswith("#") or "=" not in line:
|
|
79
|
+
out.append(line)
|
|
80
|
+
continue
|
|
81
|
+
key = stripped.split("=", 1)[0].strip()
|
|
82
|
+
if key in cleaned:
|
|
83
|
+
out.append(f"{key}={cleaned[key]}")
|
|
84
|
+
seen.add(key)
|
|
85
|
+
else:
|
|
86
|
+
out.append(line)
|
|
87
|
+
|
|
88
|
+
for key, value in cleaned.items():
|
|
89
|
+
if key not in seen:
|
|
90
|
+
out.append(f"{key}={value}")
|
|
91
|
+
os.environ[key] = value
|
|
92
|
+
|
|
93
|
+
# Keys already rewritten above still need process env updates.
|
|
94
|
+
for key, value in cleaned.items():
|
|
95
|
+
os.environ[key] = value
|
|
96
|
+
|
|
97
|
+
text = "\n".join(out).rstrip() + "\n"
|
|
98
|
+
path.write_text(text, encoding="utf-8")
|
|
99
|
+
return path
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pairwit"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.4"
|
|
4
4
|
description = "Consent-first coding agent for your terminal (Plan/Build, approvals, /schedule)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -41,7 +41,7 @@ packages = ["pairwit"]
|
|
|
41
41
|
"pairwit/assets/pairwit-mascot-8bit.png" = "pairwit/assets/pairwit-mascot-8bit.png"
|
|
42
42
|
|
|
43
43
|
[tool.hatch.build.targets.sdist]
|
|
44
|
-
include = [
|
|
44
|
+
only-include = [
|
|
45
45
|
"pairwit",
|
|
46
46
|
"README.md",
|
|
47
47
|
"pyproject.toml",
|
pairwit-0.0.2/site/README.md
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
# pairwit.dev site
|
|
2
|
-
|
|
3
|
-
Static site for [pairwit.dev](https://pairwit.dev).
|
|
4
|
-
|
|
5
|
-
| Page | File |
|
|
6
|
-
|---|---|
|
|
7
|
-
| Landing | `index.html` |
|
|
8
|
-
| User guide | `guide.html` |
|
|
9
|
-
| Styles (brand locked) | `styles.css` |
|
|
10
|
-
| Behavior (howto walkthrough) | `script.js` |
|
|
11
|
-
|
|
12
|
-
## Brand — LOCKED
|
|
13
|
-
|
|
14
|
-
Do not change fonts, palette, or light theme without an explicit brand decision.
|
|
15
|
-
Matches the kimicode-light TUI.
|
|
16
|
-
|
|
17
|
-
**Visual references (product UI):**
|
|
18
|
-
|
|
19
|
-
Marketing page uses crisp HTML/`pw-frame` terminal recreations (same chrome everywhere). Soft JPEG captures are archived until we shoot retina PNGs.
|
|
20
|
-
|
|
21
|
-
| File | What it documents |
|
|
22
|
-
|---|---|
|
|
23
|
-
| `assets/pairwit-mascot-8bit.png` | 8-bit cat + 2-wheel robot mascot (nav, hero, mocks, OG, favicon source) |
|
|
24
|
-
| `assets/favicon.ico` / `favicon-16.png` / `favicon-32.png` / `favicon-48.png` / `apple-touch-icon.png` | Brand icons from the mascot |
|
|
25
|
-
| `favicon.ico` (site root) | Browser auto-discovery at `/favicon.ico` |
|
|
26
|
-
| `docs/site-captures/ref-desk.png` | Older desk capture (soft JPEG) |
|
|
27
|
-
| `docs/site-captures/ref-chat.png` | Older chat capture (soft JPEG) |
|
|
28
|
-
| `site/assets/shot-desk.png` / `shot-chat.png` | Cropped exports — interim only |
|
|
29
|
-
|
|
30
|
-
How-to + feature mocks on the landing stay faithful to the live TUI chrome. Site uses the PNG; TUI welcome uses an ASCII sprite of the same cat-robot (`WelcomeMascot`) — half-block PNG downscales look muddy in terminal. PNG also ships in `pairwit/assets/`.
|
|
31
|
-
|
|
32
|
-
| Token | Value | Role |
|
|
33
|
-
|---|---|---|
|
|
34
|
-
| Canvas | `#FDFCF5` | Page / TUI background |
|
|
35
|
-
| Alt surface | `#F5F4F1` | Section bands, code chips |
|
|
36
|
-
| Surface | `#FFFFFF` | Cards / shells |
|
|
37
|
-
| Text | `#3D3929` | Primary ink |
|
|
38
|
-
| Muted | `#8A8580` | Secondary copy |
|
|
39
|
-
| Accent | `#0079BF` | Links, step numbers, grips |
|
|
40
|
-
| Accent deep | `#026AA7` | Hover |
|
|
41
|
-
| Border | `#E8E4D9` | Rules / chrome |
|
|
42
|
-
| Green | `#6BAA84` | Success / completion tokens |
|
|
43
|
-
| Orange | `#D4A574` | Cost / warm accents |
|
|
44
|
-
| Brand font | **Sora** | Pairwit wordmark, headings |
|
|
45
|
-
| Body font | **IBM Plex Sans** | Prose |
|
|
46
|
-
| Mono font | **IBM Plex Mono** | Commands, shortcuts |
|
|
47
|
-
|
|
48
|
-
Theme: light only (no dark mode on the marketing site).
|
|
49
|
-
|
|
50
|
-
## Next (do not skip) — agent reminder
|
|
51
|
-
|
|
52
|
-
**Status:** not done yet. Finish before calling the marketing site “shipped.”
|
|
53
|
-
|
|
54
|
-
1. ~~**Mobile-first polish**~~ — done (nav menu, stacked hero/howto desk, safe-area, 44px targets). Re-check on real devices before ship.
|
|
55
|
-
2. **Cross-browser** — verify Chromium, Safari (desktop + iOS), Firefox; check `background-clip: text`, sticky nav, clipboard copy fallback already in `script.js`.
|
|
56
|
-
3. ~~**gtag**~~ — GA4 `G-LJ45HH53S5` on `index.html` + `guide.html`. Consent banner still optional later.
|
|
57
|
-
4. ~~**Deploy → Cloudflare Pages**~~ — project `pairwit` live at https://pairwit.pages.dev. Custom domains `pairwit.dev` / `www` registered but **DNS pending** (CNAME → `pairwit.pages.dev` or move zone to Cloudflare).
|
|
58
|
-
|
|
59
|
-
## Deploy status
|
|
60
|
-
|
|
61
|
-
| Target | URL |
|
|
62
|
-
|---|---|
|
|
63
|
-
| Pages production | https://pairwit.pages.dev |
|
|
64
|
-
| Custom domain | `pairwit.dev` + `www` added on Pages (DNS still pending) |
|
|
65
|
-
|
|
66
|
-
### Redeploy
|
|
67
|
-
|
|
68
|
-
Requires Node ≥ 22:
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
export NVM_DIR="$HOME/.nvm" && . "$NVM_DIR/nvm.sh" && nvm use 22
|
|
72
|
-
cd site
|
|
73
|
-
npx wrangler@latest pages deploy . --project-name pairwit --branch main --commit-dirty=true
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### DNS for pairwit.dev
|
|
77
|
-
|
|
78
|
-
Zone **pairwit.dev** is on Cloudflare (status pending until nameservers update).
|
|
79
|
-
|
|
80
|
-
**At Hostinger (registrar)** set nameservers to:
|
|
81
|
-
|
|
82
|
-
- `kristin.ns.cloudflare.com`
|
|
83
|
-
- `quentin.ns.cloudflare.com`
|
|
84
|
-
|
|
85
|
-
Then remove Hostinger parking DNS. Apex + `www` CNAMEs to `pairwit.pages.dev` are set in the Cloudflare zone (proxied).
|
|
86
|
-
|
|
87
|
-
Dashboard: https://dash.cloudflare.com → select **pairwit.dev** → Overview (shows nameserver instructions).
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
```bash
|
|
91
|
-
cd site
|
|
92
|
-
python3 -m http.server 8080
|
|
93
|
-
# open http://127.0.0.1:8080
|
|
94
|
-
# guide: http://127.0.0.1:8080/guide.html
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
## Temporary Hostinger fallback
|
|
98
|
-
|
|
99
|
-
Only if Pages is not ready yet:
|
|
100
|
-
|
|
101
|
-
1. Hostinger → manage **pairwit.dev**
|
|
102
|
-
2. Upload `index.html`, `guide.html`, `styles.css`, `script.js` (+ optional `assets/`) to `public_html/`
|
|
103
|
-
3. Visit https://pairwit.dev
|
|
104
|
-
|
|
105
|
-
Install one-liner on the page: `pip install pairwit && pairwit`
|
|
106
|
-
(Agent release on PyPI is still gated — update copy when you green-light the full package.)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|