thwip-cli 1.9.0__tar.gz → 1.9.2__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.
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/PKG-INFO +1 -1
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/docs/verification.md +2 -1
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/pyproject.toml +1 -1
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_audit_regressions.py +11 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_handoff.py +2 -3
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_memory.py +16 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_parity_commands.py +5 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/__init__.py +1 -1
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/cli.py +49 -32
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/memory.py +7 -1
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/session.py +2 -1
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/theme.py +7 -1
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/uv.lock +1 -1
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/.github/workflows/publish.yml +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/.gitignore +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/LICENSE +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/README.md +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/docs/handoff-research.md +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/install.sh +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/__init__.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/fake_providers.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_agents.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_catalog.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_cli.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_compatible_streaming.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_config.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_detector.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_direct_providers_e2e.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_native_agents.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_native_launcher.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_native_print.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_native_rpc.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_repair_verification.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_session.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_tools.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_utils.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/__main__.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/base.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/catalog.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/chat_messages.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/claude_agent.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/google_agent.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/native_agent.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/native_common.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/native_print.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/native_rpc.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/openai_agent.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/config.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/data/context_template.md +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/data/thwip.1 +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/detector.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/endpoints.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/handoff.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/limits.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/shortcuts.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/terminal.py +0 -0
- {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thwip-cli
|
|
3
|
-
Version: 1.9.
|
|
3
|
+
Version: 1.9.2
|
|
4
4
|
Summary: Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly
|
|
5
5
|
Project-URL: Homepage, https://github.com/tanmayhutt/thwip-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/tanmayhutt/thwip-cli
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Verification status
|
|
2
2
|
|
|
3
|
-
Verified locally on 2026-09-25 for v1.9.
|
|
3
|
+
Verified locally on 2026-09-25 for v1.9.1. The Python suite
|
|
4
4
|
passes 286 offline tests on Python 3.11, 3.12, and 3.13. Exhaustive behavior across every provider and
|
|
5
5
|
configuration has not been established.
|
|
6
6
|
|
|
@@ -19,6 +19,7 @@ each using its existing sign-in. No API keys were configured.
|
|
|
19
19
|
| Ctrl+C during a response | Turn cancelled, child process terminated, REPL continued, unanswered message removed |
|
|
20
20
|
| Ctrl+C at a Codex permission prompt | Turn cancelled, no file created, no leftover process, REPL continued |
|
|
21
21
|
| Usage-limit failover | With a test-only shim making Codex report "You've hit your usage limit", the real REPL showed the alternatives, switched to Claude Code on `1`, retried the message, and answered; history held one clean pair |
|
|
22
|
+
| First-run and returning-user walkthrough (v1.9.2) | Driven as a brand-new user from an empty config: onboarding, chat, `/memory init`, compact `/switch`, `/status`, `/memory update` with diff, quit offer; then restart, `/resume`, `/history`, `/memory`, Ctrl+C exit with the memory offer. Fixed on the way: `/switch` printed four blocks (now one line unless a capability is lost), long paths wrapped every panel (now shown relative to home), the template wrote an empty `tags: []`, `/status` showed an API-key field for native agents, sessions stored the project as `.` so `/resume` from another directory pointed at the wrong project, `/resume <number>` was not accepted, and Ctrl+C at the prompt skipped the memory offer |
|
|
22
23
|
| Config sections (v1.9.0) | Found and fixed: the loader's section whitelist dropped `[memory]` and `[endpoints]`, so onboarding repeated and file-based endpoint overrides were ignored. Round-trip regression test added |
|
|
23
24
|
| Vault sync all (v1.9.0) | `/memory sync all` over the three project collections filed 26 cards under `Projects/thwip/` with Stack, Areas, and Tags hubs and a dashboard inside that folder, and left every hand-written note untouched |
|
|
24
25
|
| Real vault connection (v1.8.1) | Onboarding in the thwip project chose the real Obsidian vault; `/memory sync` wrote nothing and reported the three hand-written notes it left untouched; stack and purpose now fall back to the Snapshot body so existing context files link by stack |
|
|
@@ -266,3 +266,14 @@ async def test_failover_chain_model_must_be_listed_by_provider(cli, monkeypatch)
|
|
|
266
266
|
cli.current_agent = cli.registry.get_agent('openai')
|
|
267
267
|
await cli.handle_limit_failover(LimitHit(error_type=LimitStatus.QUOTA_EXHAUSTED, message='usage limit'), {'openai'})
|
|
268
268
|
assert switched == [('claude', 'fable')]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_cli_stores_absolute_project_paths(tmp_path, monkeypatch):
|
|
272
|
+
"""A relative project path saved into a session made /memory depend on the current directory after /resume."""
|
|
273
|
+
monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path / "cfg"))
|
|
274
|
+
monkeypatch.chdir(tmp_path)
|
|
275
|
+
from thwip import cli as cli_module
|
|
276
|
+
|
|
277
|
+
monkeypatch.setattr(cli_module.ThwipConfig, "load", classmethod(lambda cls: ThwipConfig(project=".")))
|
|
278
|
+
cli = cli_module.ThwipCLI()
|
|
279
|
+
assert cli.session.project_path == str(tmp_path) and cli.config.project == str(tmp_path)
|
|
@@ -160,13 +160,12 @@ async def test_switch_previews_before_mutating(tmp_path, monkeypatch):
|
|
|
160
160
|
calls = []
|
|
161
161
|
|
|
162
162
|
def preview(*args):
|
|
163
|
-
assert cli.current_agent is source
|
|
164
163
|
calls.append(args)
|
|
165
164
|
|
|
166
165
|
monkeypatch.setattr(cli, "cmd_handoff", preview)
|
|
167
166
|
await cli.cmd_switch("offline", "test-model")
|
|
168
|
-
assert calls == [
|
|
169
|
-
assert cli.current_agent is target
|
|
167
|
+
assert calls == [], "/switch no longer prints the full handoff table; /handoff does"
|
|
168
|
+
assert cli.current_agent is target and cli.session.current_agent == "offline"
|
|
170
169
|
|
|
171
170
|
|
|
172
171
|
@pytest.mark.parametrize("command", ["/clear", "/session clear"])
|
|
@@ -249,3 +249,19 @@ def test_sync_all_discovers_projects_and_files_cards_in_a_subfolder(tmp_path):
|
|
|
249
249
|
assert "[[Projects/thwip/Stack/Python|Python]]" in alpha, "hub links point into thwip's own folder"
|
|
250
250
|
assert (tmp_path / "brain" / "Projects" / "thwip" / "Dashboard.md").is_file(), "dashboard goes in the cards folder when it is not Projects/"
|
|
251
251
|
assert not (tmp_path / "brain" / "Projects.md").exists() and not (tmp_path / "brain" / "Stack").exists()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_rebuild_tolerates_cards_without_a_context_path(tmp_path):
|
|
255
|
+
from thwip.memory import Vault
|
|
256
|
+
|
|
257
|
+
vault = Vault(str(tmp_path / "brain"), cards_dir="Projects/thwip")
|
|
258
|
+
vault.create()
|
|
259
|
+
(vault.cards_root / "old.md").write_text("---\nproject: old\ngenerated_by: thwip\n---\n\n# old\n")
|
|
260
|
+
project = tmp_path / "fresh"
|
|
261
|
+
project.mkdir()
|
|
262
|
+
ProjectMemory(str(project)).write("---\nproject: fresh\narea: X\n---\n\n# fresh\n")
|
|
263
|
+
report = vault.sync_all([ProjectMemory(str(project))])
|
|
264
|
+
assert any(path.endswith("fresh.md") for path in report["written"])
|
|
265
|
+
assert "context file `unknown`" in (vault.cards_root / "old.md").read_text()
|
|
266
|
+
report = vault.sync_all([ProjectMemory(str(project))])
|
|
267
|
+
assert [c.name for c in vault.cards()] == ["fresh", "old"], "Dashboard.md is not treated as a project card"
|
|
@@ -98,6 +98,11 @@ async def test_new_saves_previous_and_resume_restores_it(cli, monkeypatch):
|
|
|
98
98
|
cli.cmd_new()
|
|
99
99
|
await cli.cmd_resume(old_name)
|
|
100
100
|
assert cli.session.name == old_name
|
|
101
|
+
cli.cmd_new()
|
|
102
|
+
first = Session.list_saved_sessions()[0]["name"]
|
|
103
|
+
await cli.cmd_resume("1")
|
|
104
|
+
assert cli.session.name == first, "/resume 1 picks the first listed session"
|
|
105
|
+
assert cli.session.project_path.startswith("/"), "loaded project path is absolute"
|
|
101
106
|
|
|
102
107
|
|
|
103
108
|
@pytest.mark.asyncio
|
|
@@ -103,6 +103,13 @@ class QuietTerminal:
|
|
|
103
103
|
return False
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
def pretty_path(path) -> str:
|
|
107
|
+
"""Show paths relative to the home directory so panels stop wrapping."""
|
|
108
|
+
text = str(path)
|
|
109
|
+
home = str(Path.home())
|
|
110
|
+
return "~" + text[len(home):] if text.startswith(home + os.sep) or text == home else text
|
|
111
|
+
|
|
112
|
+
|
|
106
113
|
class TurnInterrupted(Exception):
|
|
107
114
|
"""Raised when the user presses Ctrl+C at a prompt shown during a turn."""
|
|
108
115
|
|
|
@@ -123,6 +130,8 @@ class ThwipCLI:
|
|
|
123
130
|
self.config = ThwipConfig.load()
|
|
124
131
|
if project:
|
|
125
132
|
self.config.project = project
|
|
133
|
+
# Sessions and memory files are keyed by project; never let that depend on the current directory later.
|
|
134
|
+
self.config.project = os.path.abspath(os.path.expanduser(self.config.project or "."))
|
|
126
135
|
self.registry = AgentRegistry(self.config)
|
|
127
136
|
self.detector = SystemDetector()
|
|
128
137
|
self.usage_tracker = UsageTracker()
|
|
@@ -260,7 +269,12 @@ class ThwipCLI:
|
|
|
260
269
|
await self._run_interruptible(self.process_user_message(self._expand_mentions(user_input)))
|
|
261
270
|
|
|
262
271
|
except (KeyboardInterrupt, EOFError):
|
|
263
|
-
console.print(
|
|
272
|
+
console.print()
|
|
273
|
+
try:
|
|
274
|
+
await self._run_interruptible(self._offer_memory_update("quitting"))
|
|
275
|
+
except (KeyboardInterrupt, EOFError):
|
|
276
|
+
pass
|
|
277
|
+
console.print("[dim]Exiting thwip. Goodbye![/dim]")
|
|
264
278
|
break
|
|
265
279
|
except Exception as e:
|
|
266
280
|
print_error(f"Unexpected error: {e}")
|
|
@@ -462,6 +476,7 @@ class ThwipCLI:
|
|
|
462
476
|
if not agent.get_model_info(loaded.current_model):
|
|
463
477
|
loaded.current_model = agent.get_default_model()
|
|
464
478
|
print_warning("The saved model is unavailable. Using the provider default.")
|
|
479
|
+
loaded.project_path = str(project)
|
|
465
480
|
self.session = loaded
|
|
466
481
|
self.current_agent = agent
|
|
467
482
|
self.tool_manager = ToolManager(str(project))
|
|
@@ -503,11 +518,13 @@ class ThwipCLI:
|
|
|
503
518
|
print_success(f"Started new session {self.session.name}.")
|
|
504
519
|
|
|
505
520
|
async def cmd_resume(self, name: str = "") -> None:
|
|
506
|
-
"""Resume a saved session by name or from a numbered list, like /resume in Codex and Claude Code."""
|
|
521
|
+
"""Resume a saved session by name, by list number, or from a numbered list, like /resume in Codex and Claude Code."""
|
|
522
|
+
sessions = Session.list_saved_sessions()
|
|
507
523
|
if name:
|
|
524
|
+
if name.isdigit() and sessions and 1 <= int(name) <= len(sessions):
|
|
525
|
+
name = sessions[int(name) - 1]["name"]
|
|
508
526
|
self._load_session(name)
|
|
509
527
|
return
|
|
510
|
-
sessions = Session.list_saved_sessions()
|
|
511
528
|
if not sessions:
|
|
512
529
|
print_info("No saved sessions found.")
|
|
513
530
|
return
|
|
@@ -719,14 +736,14 @@ class ThwipCLI:
|
|
|
719
736
|
try:
|
|
720
737
|
vault.create()
|
|
721
738
|
except OSError as exc:
|
|
722
|
-
print_error(f"Could not create the vault folder: {exc}")
|
|
739
|
+
print_error(f"Could not create the vault folder at {pretty_path(chosen)}: {exc}")
|
|
723
740
|
chosen = ""
|
|
724
741
|
cfg.vault = chosen
|
|
725
742
|
cfg.onboarded = True
|
|
726
743
|
with contextlib.suppress(Exception):
|
|
727
744
|
self.config.save()
|
|
728
745
|
if chosen:
|
|
729
|
-
print_success(f"Second brain connected: {chosen}. Use /memory to view or update
|
|
746
|
+
print_success(f"Second brain connected: {pretty_path(chosen)}. Use /memory in any project to view or update its memory.")
|
|
730
747
|
else:
|
|
731
748
|
print_info("No vault connected. Project memory still works locally; set one later with /memory vault <path>.")
|
|
732
749
|
|
|
@@ -744,14 +761,16 @@ class ThwipCLI:
|
|
|
744
761
|
console.print(Text(render_frontmatter(data), style="dim"))
|
|
745
762
|
console.print(self._render_response(body))
|
|
746
763
|
vault = self._vault()
|
|
747
|
-
console.print(Text(f"File: {memory.path}" + (f" | Vault: {vault.root}" if vault else " | No vault connected (/memory vault <path>)"), style="dim"))
|
|
764
|
+
console.print(Text(f"File: {pretty_path(memory.path)}" + (f" | Vault: {pretty_path(vault.root)}" if vault else " | No vault connected (/memory vault <path>)"), style="dim"))
|
|
748
765
|
elif sub == "init":
|
|
749
766
|
if memory.exists():
|
|
750
767
|
print_info(f"{cfg.file} already exists.")
|
|
751
768
|
return
|
|
752
769
|
area = rest or "General"
|
|
753
770
|
memory.init(area=area)
|
|
754
|
-
|
|
771
|
+
detected = memory.frontmatter().get("stack") or []
|
|
772
|
+
found = f" ({', '.join(detected)} detected)" if detected else ""
|
|
773
|
+
print_success(f"Created {cfg.file}{found}. Edit it, or run /memory update after a conversation.")
|
|
755
774
|
self._sync_vault(memory)
|
|
756
775
|
elif sub == "edit":
|
|
757
776
|
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
@@ -810,7 +829,7 @@ class ThwipCLI:
|
|
|
810
829
|
return
|
|
811
830
|
try:
|
|
812
831
|
report = vault.sync_all(memories)
|
|
813
|
-
except OSError as exc:
|
|
832
|
+
except (OSError, ValueError) as exc:
|
|
814
833
|
print_error(f"Vault sync failed: {exc}")
|
|
815
834
|
return
|
|
816
835
|
print_success(f"Filed {report['projects']} projects from {', '.join(roots)}: {len(report['written'])} notes written or refreshed "
|
|
@@ -826,12 +845,12 @@ class ThwipCLI:
|
|
|
826
845
|
return
|
|
827
846
|
try:
|
|
828
847
|
report = vault.sync(memory)
|
|
829
|
-
except OSError as exc:
|
|
848
|
+
except (OSError, ValueError) as exc:
|
|
830
849
|
print_error(f"Vault sync failed: {exc}")
|
|
831
850
|
return
|
|
832
851
|
if verbose or report["written"]:
|
|
833
852
|
written = len(report["written"])
|
|
834
|
-
print_success(f"Filed in the vault
|
|
853
|
+
print_success(f"Filed in the vault ({written} note{'s' if written != 1 else ''} refreshed under {pretty_path(vault.cards_root)}).")
|
|
835
854
|
for path in report["skipped"]:
|
|
836
855
|
print_warning(f"Left untouched (not created by thwip): {path}")
|
|
837
856
|
|
|
@@ -903,7 +922,7 @@ class ThwipCLI:
|
|
|
903
922
|
print_info("Project memory unchanged.")
|
|
904
923
|
return False
|
|
905
924
|
memory.write(proposed)
|
|
906
|
-
print_success(f"Project memory updated: {memory.path}")
|
|
925
|
+
print_success(f"Project memory updated: {pretty_path(memory.path)}")
|
|
907
926
|
self._sync_vault(memory)
|
|
908
927
|
return True
|
|
909
928
|
|
|
@@ -1063,7 +1082,7 @@ class ThwipCLI:
|
|
|
1063
1082
|
f"{report.excluded_tool_calls} stored tool-call entries")),
|
|
1064
1083
|
("Transient results", f"{report.observed_tool_results} observed tool results not transferred"),
|
|
1065
1084
|
("Tracking coverage", "Since session creation" if report.tracking_complete
|
|
1066
|
-
else "Partial:
|
|
1085
|
+
else "Partial: native CLI tool activity is not counted"),
|
|
1067
1086
|
("Capabilities lost", ", ".join(report.lost_capabilities) or "None in local catalog"),
|
|
1068
1087
|
("Capabilities gained", ", ".join(report.gained_capabilities) or "None in local catalog"),
|
|
1069
1088
|
("Context pressure", (f"{report.context_pressure}: ~{report.estimated_input_tokens:,} input "
|
|
@@ -1148,26 +1167,27 @@ class ThwipCLI:
|
|
|
1148
1167
|
|
|
1149
1168
|
old_agent = self.current_agent
|
|
1150
1169
|
old_caps = old_agent.get_capabilities_for_model(self.session.current_model)
|
|
1151
|
-
|
|
1152
|
-
self.cmd_handoff(new_agent.name, chosen_model)
|
|
1170
|
+
carried = len(self.session.to_portable_messages())
|
|
1153
1171
|
|
|
1154
1172
|
self.current_agent = new_agent
|
|
1155
1173
|
self.session.switch_agent(new_agent.name, chosen_model)
|
|
1156
1174
|
|
|
1157
|
-
#
|
|
1175
|
+
# Only interrupt with the capability panel when something is actually lost; /handoff has the full report.
|
|
1158
1176
|
new_capabilities = new_agent.get_capabilities_for_model(chosen_model)
|
|
1159
1177
|
missing = [
|
|
1160
1178
|
capability.display_name
|
|
1161
1179
|
for capability in sorted(old_caps - new_capabilities, key=lambda item: item.value)
|
|
1162
1180
|
]
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1181
|
+
if missing:
|
|
1182
|
+
console.print(
|
|
1183
|
+
render_capability_disclaimer(
|
|
1184
|
+
agent_name=new_agent.display_name,
|
|
1185
|
+
company=new_agent.company,
|
|
1186
|
+
supported=[c.display_name for c in new_capabilities],
|
|
1187
|
+
unsupported=missing,
|
|
1188
|
+
)
|
|
1169
1189
|
)
|
|
1170
|
-
|
|
1190
|
+
carried_text = f"{carried} message{'s' if carried != 1 else ''} carried over" if carried else "fresh conversation"
|
|
1171
1191
|
|
|
1172
1192
|
if not new_agent.is_configured():
|
|
1173
1193
|
key_name = {
|
|
@@ -1184,15 +1204,11 @@ class ThwipCLI:
|
|
|
1184
1204
|
f" Or add it to ~/.thwip/config.toml"
|
|
1185
1205
|
)
|
|
1186
1206
|
elif getattr(new_agent, "native_tools", False):
|
|
1187
|
-
print_success(
|
|
1188
|
-
|
|
1189
|
-
"Portable text history preserved."
|
|
1190
|
-
)
|
|
1207
|
+
print_success(f"Now chatting with {new_agent.display_name} ({chosen_model}) through its existing sign-in; "
|
|
1208
|
+
f"{carried_text}. /handoff shows the full transfer report.")
|
|
1191
1209
|
else:
|
|
1192
|
-
print_success(
|
|
1193
|
-
|
|
1194
|
-
"Portable text history preserved."
|
|
1195
|
-
)
|
|
1210
|
+
print_success(f"Now chatting with {new_agent.display_name} ({chosen_model}); {carried_text}. "
|
|
1211
|
+
"/handoff shows the full transfer report.")
|
|
1196
1212
|
|
|
1197
1213
|
def cmd_show_agents(self) -> None:
|
|
1198
1214
|
"""Show table of all detected agents."""
|
|
@@ -1408,7 +1424,7 @@ class ThwipCLI:
|
|
|
1408
1424
|
content = Text()
|
|
1409
1425
|
content.append(f"Agent: {self.current_agent.display_name} ({self.current_agent.company})\n", style=brand.label_style)
|
|
1410
1426
|
content.append(f"Model: {self.session.current_model}\n", style="bold white")
|
|
1411
|
-
content.append(f"Project: {os.path.abspath(self.session.project_path)}\n", style="white")
|
|
1427
|
+
content.append(f"Project: {pretty_path(os.path.abspath(self.session.project_path))}\n", style="white")
|
|
1412
1428
|
content.append(f"Session: {self.session.name} ({len(self.session.messages)} messages)\n", style="white")
|
|
1413
1429
|
content.append(f"Tokens: {self.session.get_total_tokens():,} used\n", style="dim")
|
|
1414
1430
|
if getattr(self.current_agent, "native_tools", False):
|
|
@@ -1419,7 +1435,8 @@ class ThwipCLI:
|
|
|
1419
1435
|
content.append(f"CLI session: {record['id']} (has seen {record['synced']} messages; only new ones are sent)\n", style="dim")
|
|
1420
1436
|
else:
|
|
1421
1437
|
content.append("CLI session: none yet; the first message sends the full conversation\n", style="dim")
|
|
1422
|
-
|
|
1438
|
+
if not getattr(self.current_agent, "native_tools", False):
|
|
1439
|
+
content.append(f"Config Key: {self.config.key_sources.get(self.current_agent.name, 'None')}\n", style="dim")
|
|
1423
1440
|
|
|
1424
1441
|
console.print(Panel(content, title="Current Status", box=box.ROUNDED))
|
|
1425
1442
|
|
|
@@ -170,6 +170,8 @@ class ProjectMemory:
|
|
|
170
170
|
stack=", ".join(facts["stack"]), stack_text=", ".join(facts["stack"]) or "not detected",
|
|
171
171
|
tags=", ".join(tags or []), entry_points=facts["entry_points"], runtime=facts["runtime"],
|
|
172
172
|
)
|
|
173
|
+
# The format says: do not add empty fields.
|
|
174
|
+
text = "\n".join(line for line in text.splitlines() if line.strip() not in {"tags: []", "stack: []"}) + "\n"
|
|
173
175
|
self.write(text)
|
|
174
176
|
return text
|
|
175
177
|
|
|
@@ -317,6 +319,8 @@ class Vault:
|
|
|
317
319
|
if MARKER not in text[:600]:
|
|
318
320
|
continue
|
|
319
321
|
data, _ = parse_frontmatter(text)
|
|
322
|
+
if "project" not in data:
|
|
323
|
+
continue # the dashboard and hub notes also carry the marker
|
|
320
324
|
found.append(ProjectCard(name=str(data.get("project", note.stem)), context_path=str(data.get("context_path", "")),
|
|
321
325
|
area=str(data.get("area", "")), stack=_as_list(data.get("stack")), tags=_as_list(data.get("tags")),
|
|
322
326
|
status=str(data.get("status", "")), purpose=str(data.get("purpose", "")), updated=str(data.get("updated", ""))))
|
|
@@ -366,8 +370,10 @@ class Vault:
|
|
|
366
370
|
}), "", f"# {card.name}", ""]
|
|
367
371
|
if card.purpose:
|
|
368
372
|
lines += [card.purpose, ""]
|
|
373
|
+
context = Path(card.context_path) if card.context_path else None
|
|
374
|
+
link = f"[context file]({context.as_uri()})" if context and context.is_absolute() else f"context file `{card.context_path or 'unknown'}`"
|
|
369
375
|
lines += ["## Technical source", "",
|
|
370
|
-
(f"Current work, decisions, and history live in the project's
|
|
376
|
+
(f"Current work, decisions, and history live in the project's {link}. "
|
|
371
377
|
"This card is a stable index entry maintained by thwip; do not put changing detail here."), ""]
|
|
372
378
|
if card.area or card.stack or card.tags:
|
|
373
379
|
lines += ["## Links", ""]
|
|
@@ -287,4 +287,5 @@ class Session:
|
|
|
287
287
|
})
|
|
288
288
|
except Exception:
|
|
289
289
|
continue
|
|
290
|
-
|
|
290
|
+
# Newest first; ties broken by name so numbered picks stay stable between calls.
|
|
291
|
+
return sorted(result, key=lambda x: (x["updated_at"], x["name"]), reverse=True)
|
|
@@ -20,6 +20,12 @@ from rich.theme import Theme
|
|
|
20
20
|
|
|
21
21
|
from thwip import __version__
|
|
22
22
|
|
|
23
|
+
|
|
24
|
+
def _pretty(path: str) -> str:
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
home = str(Path.home())
|
|
27
|
+
return '~' + path[len(home):] if path.startswith(home + '/') or path == home else path
|
|
28
|
+
|
|
23
29
|
# ---------------------------------------------------------------------------
|
|
24
30
|
# Company / Provider Brand Colors
|
|
25
31
|
# ---------------------------------------------------------------------------
|
|
@@ -155,7 +161,7 @@ def render_startup_banner(
|
|
|
155
161
|
lines.append(" Model: ", style="dim")
|
|
156
162
|
lines.append(f"{model}\n", style="bold white")
|
|
157
163
|
lines.append(" Workspace: ", style="dim")
|
|
158
|
-
lines.append(f"{project_path}\n", style="white")
|
|
164
|
+
lines.append(f"{_pretty(project_path)}\n", style="white")
|
|
159
165
|
lines.append(" Session: ", style="dim")
|
|
160
166
|
lines.append(f"{session_name}\n", style="white")
|
|
161
167
|
lines.append(" Environment: ", style="dim")
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|