pairwit 0.0.2__tar.gz → 0.0.3__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.3}/PKG-INFO +3 -1
- {pairwit-0.0.2 → pairwit-0.0.3}/README.md +2 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/__init__.py +1 -1
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/app.py +67 -10
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/__init__.py +2 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/modals.py +252 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/util/env.py +44 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pyproject.toml +2 -2
- pairwit-0.0.2/site/README.md +0 -106
- {pairwit-0.0.2 → pairwit-0.0.3}/.gitignore +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ARCHITECTURE.md +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/__main__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/analytics.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/context.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/errors.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/events.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/loop.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/stream.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/agent/turn.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/catalog.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/clients.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/providers.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/ai/types.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/assets/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/assets/pairwit-mascot-8bit.png +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/core.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/context.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/git.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/paths.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/permissions.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/prompts.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/schedules.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/domain/sessions.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/approval.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/base.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/constants.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/execution.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/mcp.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/modes.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/registry.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/result.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/schemas.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tools/types.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/__init__.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/clipboard.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/theme.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/cards.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/chrome.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/editors.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/explorer.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/formatters.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/input_area.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/schedule.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/pairwit/tui/widgets/tools_ui.py +0 -0
- {pairwit-0.0.2 → pairwit-0.0.3}/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.3
|
|
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 provider setup (API key → saved 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 provider setup (API key → saved 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
|
|
@@ -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"Provider ready: `{self._provider}` · `{self._model}`.\n"
|
|
763
|
+
f"Saved to `{env_path}`."
|
|
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",
|
|
@@ -761,3 +761,255 @@ class TreeContextMenu(ModalScreen[Optional[str]]):
|
|
|
761
761
|
def on_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
762
762
|
event.stop()
|
|
763
763
|
self.dismiss(event.option_id)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class ProviderSetupScreen(ModalScreen[dict[str, str] | None]):
|
|
767
|
+
"""First-run / missing-key wizard: pick provider, enter key, save ~/.pairwit/.env."""
|
|
768
|
+
|
|
769
|
+
BINDINGS = [
|
|
770
|
+
Binding("escape", "cancel", "Cancel", show=False),
|
|
771
|
+
]
|
|
772
|
+
|
|
773
|
+
DEFAULT_CSS = f"""
|
|
774
|
+
ProviderSetupScreen {{
|
|
775
|
+
align: center middle;
|
|
776
|
+
background: #091e42 78%;
|
|
777
|
+
}}
|
|
778
|
+
|
|
779
|
+
ProviderSetupScreen #setup-modal-box {{
|
|
780
|
+
width: 72;
|
|
781
|
+
height: auto;
|
|
782
|
+
max-height: 24;
|
|
783
|
+
border: tall {KIMI_BLUE};
|
|
784
|
+
background: {KIMI_SURFACE};
|
|
785
|
+
padding: 1 2;
|
|
786
|
+
}}
|
|
787
|
+
|
|
788
|
+
ProviderSetupScreen #setup-title {{
|
|
789
|
+
color: {KIMI_TEXT};
|
|
790
|
+
text-style: bold;
|
|
791
|
+
padding: 0 0 1 0;
|
|
792
|
+
}}
|
|
793
|
+
|
|
794
|
+
ProviderSetupScreen #setup-hint {{
|
|
795
|
+
color: {KIMI_MUTED};
|
|
796
|
+
padding: 0 0 1 0;
|
|
797
|
+
}}
|
|
798
|
+
|
|
799
|
+
ProviderSetupScreen #setup-error {{
|
|
800
|
+
color: {KIMI_ORANGE};
|
|
801
|
+
padding: 0 0 1 0;
|
|
802
|
+
}}
|
|
803
|
+
|
|
804
|
+
ProviderSetupScreen OptionList {{
|
|
805
|
+
height: auto;
|
|
806
|
+
max-height: 12;
|
|
807
|
+
border: solid {KIMI_BORDER};
|
|
808
|
+
}}
|
|
809
|
+
|
|
810
|
+
ProviderSetupScreen .option-list--option-highlighted {{
|
|
811
|
+
background: {KIMI_BLUE} 18%;
|
|
812
|
+
color: {KIMI_TEXT};
|
|
813
|
+
}}
|
|
814
|
+
|
|
815
|
+
ProviderSetupScreen Input {{
|
|
816
|
+
border: solid {KIMI_BORDER};
|
|
817
|
+
background: {KIMI_SURFACE_ALT};
|
|
818
|
+
color: {KIMI_TEXT};
|
|
819
|
+
margin: 0 0 1 0;
|
|
820
|
+
}}
|
|
821
|
+
"""
|
|
822
|
+
|
|
823
|
+
def __init__(
|
|
824
|
+
self,
|
|
825
|
+
*,
|
|
826
|
+
current_provider: str = "moonshot",
|
|
827
|
+
reason: str | None = None,
|
|
828
|
+
initial_provider: str | None = None,
|
|
829
|
+
) -> None:
|
|
830
|
+
super().__init__()
|
|
831
|
+
self._current_provider = current_provider
|
|
832
|
+
self._reason = reason
|
|
833
|
+
self._providers = [PROVIDERS[pid] for pid in PROVIDER_ORDER if pid in PROVIDERS]
|
|
834
|
+
self._step = "pick" # pick | key
|
|
835
|
+
self._selected_id = initial_provider if initial_provider in PROVIDERS else None
|
|
836
|
+
self._error = ""
|
|
837
|
+
|
|
838
|
+
def compose(self) -> ComposeResult:
|
|
839
|
+
with Vertical(id="setup-modal-box"):
|
|
840
|
+
yield Static("Set up your AI provider", id="setup-title")
|
|
841
|
+
hint = (
|
|
842
|
+
"Pairwit needs a provider before it can chat. "
|
|
843
|
+
"Keys are saved to ~/.pairwit/.env (kept local)."
|
|
844
|
+
)
|
|
845
|
+
if self._reason:
|
|
846
|
+
hint = f"{self._reason.strip()}\n{hint}"
|
|
847
|
+
yield Static(hint, id="setup-hint")
|
|
848
|
+
yield Static("", id="setup-error")
|
|
849
|
+
yield OptionList(id="setup-provider-list")
|
|
850
|
+
yield Static("", id="setup-key-label")
|
|
851
|
+
yield Input(placeholder="API key", password=True, id="setup-api-key")
|
|
852
|
+
yield Static("", id="setup-base-label")
|
|
853
|
+
yield Input(placeholder="https://…/v1", id="setup-base-url")
|
|
854
|
+
|
|
855
|
+
def on_mount(self) -> None:
|
|
856
|
+
self._render_pick_step()
|
|
857
|
+
if self._selected_id:
|
|
858
|
+
self._enter_key_step(self._selected_id)
|
|
859
|
+
|
|
860
|
+
def _set_error(self, message: str) -> None:
|
|
861
|
+
self._error = message
|
|
862
|
+
self.query_one("#setup-error", Static).update(message)
|
|
863
|
+
|
|
864
|
+
def _render_pick_step(self) -> None:
|
|
865
|
+
self._step = "pick"
|
|
866
|
+
self._set_error("")
|
|
867
|
+
self.query_one("#setup-key-label", Static).display = False
|
|
868
|
+
self.query_one("#setup-api-key", Input).display = False
|
|
869
|
+
self.query_one("#setup-base-label", Static).display = False
|
|
870
|
+
self.query_one("#setup-base-url", Input).display = False
|
|
871
|
+
options: list[Option] = []
|
|
872
|
+
for index, spec in enumerate(self._providers):
|
|
873
|
+
if spec.id == "ollama":
|
|
874
|
+
extra = " · no API key"
|
|
875
|
+
elif spec.id == "custom":
|
|
876
|
+
extra = " · key + base URL"
|
|
877
|
+
else:
|
|
878
|
+
extra = f" · needs {spec.api_key_env}"
|
|
879
|
+
mark = " ← current" if spec.id == self._current_provider else ""
|
|
880
|
+
options.append(Option(f"{spec.label}{extra}{mark}", id=f"setup-{index}"))
|
|
881
|
+
option_list = self.query_one("#setup-provider-list", OptionList)
|
|
882
|
+
option_list.display = True
|
|
883
|
+
option_list.clear_options()
|
|
884
|
+
option_list.add_options(options)
|
|
885
|
+
option_list.focus()
|
|
886
|
+
|
|
887
|
+
def _enter_key_step(self, provider_id: str) -> None:
|
|
888
|
+
self._selected_id = provider_id
|
|
889
|
+
self._step = "key"
|
|
890
|
+
self._set_error("")
|
|
891
|
+
spec = PROVIDERS[provider_id]
|
|
892
|
+
self.query_one("#setup-provider-list", OptionList).display = False
|
|
893
|
+
|
|
894
|
+
if provider_id == "ollama":
|
|
895
|
+
self._finish_ollama()
|
|
896
|
+
return
|
|
897
|
+
|
|
898
|
+
key_label = self.query_one("#setup-key-label", Static)
|
|
899
|
+
key_input = self.query_one("#setup-api-key", Input)
|
|
900
|
+
base_label = self.query_one("#setup-base-label", Static)
|
|
901
|
+
base_input = self.query_one("#setup-base-url", Input)
|
|
902
|
+
|
|
903
|
+
key_label.display = True
|
|
904
|
+
key_input.display = True
|
|
905
|
+
key_label.update(f"{spec.label} — paste {spec.api_key_env} and press Enter")
|
|
906
|
+
key_input.value = ""
|
|
907
|
+
key_input.placeholder = spec.api_key_env or "API key"
|
|
908
|
+
|
|
909
|
+
if provider_id == "custom":
|
|
910
|
+
base_label.display = True
|
|
911
|
+
base_input.display = True
|
|
912
|
+
base_label.update("OpenAI-compatible base URL (PAIRWIT_BASE_URL)")
|
|
913
|
+
base_input.value = ""
|
|
914
|
+
base_input.focus()
|
|
915
|
+
else:
|
|
916
|
+
base_label.display = False
|
|
917
|
+
base_input.display = False
|
|
918
|
+
key_input.focus()
|
|
919
|
+
|
|
920
|
+
def _finish_ollama(self) -> None:
|
|
921
|
+
from pairwit.util.env import upsert_pairwit_env
|
|
922
|
+
|
|
923
|
+
path = upsert_pairwit_env(
|
|
924
|
+
{
|
|
925
|
+
"PAIRWIT_PROVIDER": "ollama",
|
|
926
|
+
"PAIRWIT_MODEL": PROVIDERS["ollama"].default_model,
|
|
927
|
+
}
|
|
928
|
+
)
|
|
929
|
+
self.dismiss(
|
|
930
|
+
{
|
|
931
|
+
"provider": "ollama",
|
|
932
|
+
"model": PROVIDERS["ollama"].default_model,
|
|
933
|
+
"env_path": str(path),
|
|
934
|
+
}
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def _save_cloud_provider(self) -> None:
|
|
938
|
+
from pairwit.util.env import upsert_pairwit_env
|
|
939
|
+
|
|
940
|
+
provider_id = self._selected_id
|
|
941
|
+
if not provider_id or provider_id not in PROVIDERS:
|
|
942
|
+
self._set_error("Pick a provider first.")
|
|
943
|
+
return
|
|
944
|
+
spec = PROVIDERS[provider_id]
|
|
945
|
+
key = self.query_one("#setup-api-key", Input).value.strip()
|
|
946
|
+
if not key:
|
|
947
|
+
self._set_error(f"Enter {spec.api_key_env} to continue.")
|
|
948
|
+
self.query_one("#setup-api-key", Input).focus()
|
|
949
|
+
return
|
|
950
|
+
|
|
951
|
+
values: dict[str, str] = {
|
|
952
|
+
"PAIRWIT_PROVIDER": provider_id,
|
|
953
|
+
"PAIRWIT_MODEL": spec.default_model,
|
|
954
|
+
}
|
|
955
|
+
if spec.api_key_env:
|
|
956
|
+
values[spec.api_key_env] = key
|
|
957
|
+
|
|
958
|
+
if provider_id == "custom":
|
|
959
|
+
base = self.query_one("#setup-base-url", Input).value.strip().rstrip("/")
|
|
960
|
+
if not base:
|
|
961
|
+
self._set_error("Enter PAIRWIT_BASE_URL (e.g. https://api.example.com/v1).")
|
|
962
|
+
self.query_one("#setup-base-url", Input).focus()
|
|
963
|
+
return
|
|
964
|
+
values["PAIRWIT_BASE_URL"] = base
|
|
965
|
+
|
|
966
|
+
path = upsert_pairwit_env(values)
|
|
967
|
+
# Re-check after env write.
|
|
968
|
+
remaining = _provider_missing_key_message(provider_id)
|
|
969
|
+
if remaining:
|
|
970
|
+
self._set_error(remaining.split("\n", 1)[0].replace("❌ ", ""))
|
|
971
|
+
return
|
|
972
|
+
|
|
973
|
+
self.dismiss(
|
|
974
|
+
{
|
|
975
|
+
"provider": provider_id,
|
|
976
|
+
"model": spec.default_model,
|
|
977
|
+
"env_path": str(path),
|
|
978
|
+
}
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
def action_cancel(self) -> None:
|
|
982
|
+
if self._step == "key":
|
|
983
|
+
self._selected_id = None
|
|
984
|
+
self._render_pick_step()
|
|
985
|
+
return
|
|
986
|
+
self.dismiss(None)
|
|
987
|
+
|
|
988
|
+
@on(OptionList.OptionSelected, "#setup-provider-list")
|
|
989
|
+
def on_provider_selected(self, event: OptionList.OptionSelected) -> None:
|
|
990
|
+
event.stop()
|
|
991
|
+
option_id = event.option_id or ""
|
|
992
|
+
if not option_id.startswith("setup-"):
|
|
993
|
+
return
|
|
994
|
+
try:
|
|
995
|
+
index = int(option_id.split("-", 1)[1])
|
|
996
|
+
except ValueError:
|
|
997
|
+
return
|
|
998
|
+
if index < 0 or index >= len(self._providers):
|
|
999
|
+
return
|
|
1000
|
+
self._enter_key_step(self._providers[index].id)
|
|
1001
|
+
|
|
1002
|
+
@on(Input.Submitted, "#setup-api-key")
|
|
1003
|
+
def on_api_key_submitted(self, event: Input.Submitted) -> None:
|
|
1004
|
+
event.stop()
|
|
1005
|
+
self._save_cloud_provider()
|
|
1006
|
+
|
|
1007
|
+
@on(Input.Submitted, "#setup-base-url")
|
|
1008
|
+
def on_base_url_submitted(self, event: Input.Submitted) -> None:
|
|
1009
|
+
event.stop()
|
|
1010
|
+
# After base URL, move to key field if empty, else save.
|
|
1011
|
+
key = self.query_one("#setup-api-key", Input)
|
|
1012
|
+
if not key.value.strip():
|
|
1013
|
+
key.focus()
|
|
1014
|
+
return
|
|
1015
|
+
self._save_cloud_provider()
|
|
@@ -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.3"
|
|
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
|
|
File without changes
|
|
File without changes
|