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.
Files changed (66) hide show
  1. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/PKG-INFO +1 -1
  2. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/docs/verification.md +2 -1
  3. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/pyproject.toml +1 -1
  4. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_audit_regressions.py +11 -0
  5. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_handoff.py +2 -3
  6. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_memory.py +16 -0
  7. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_parity_commands.py +5 -0
  8. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/__init__.py +1 -1
  9. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/cli.py +49 -32
  10. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/memory.py +7 -1
  11. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/session.py +2 -1
  12. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/theme.py +7 -1
  13. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/uv.lock +1 -1
  14. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/.github/workflows/publish.yml +0 -0
  15. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/.gitignore +0 -0
  16. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/LICENSE +0 -0
  17. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/README.md +0 -0
  18. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/docs/handoff-research.md +0 -0
  19. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/install.sh +0 -0
  20. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/__init__.py +0 -0
  21. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/fake_providers.py +0 -0
  22. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_agents.py +0 -0
  23. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_catalog.py +0 -0
  24. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_cli.py +0 -0
  25. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_compatible_streaming.py +0 -0
  26. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_config.py +0 -0
  27. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_detector.py +0 -0
  28. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_direct_providers_e2e.py +0 -0
  29. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_native_agents.py +0 -0
  30. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_native_launcher.py +0 -0
  31. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_native_print.py +0 -0
  32. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_native_rpc.py +0 -0
  33. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_repair_verification.py +0 -0
  34. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_session.py +0 -0
  35. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_tools.py +0 -0
  36. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/tests/test_utils.py +0 -0
  37. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/__main__.py +0 -0
  38. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/__init__.py +0 -0
  39. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/base.py +0 -0
  40. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/catalog.py +0 -0
  41. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/chat_messages.py +0 -0
  42. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/claude_agent.py +0 -0
  43. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/deepseek_agent.py +0 -0
  44. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/google_agent.py +0 -0
  45. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/groq_agent.py +0 -0
  46. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/native_agent.py +0 -0
  47. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/native_common.py +0 -0
  48. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/native_print.py +0 -0
  49. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/native_rpc.py +0 -0
  50. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/ollama_agent.py +0 -0
  51. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/openai_agent.py +0 -0
  52. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/agents/openrouter_agent.py +0 -0
  53. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/config.py +0 -0
  54. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/data/context_template.md +0 -0
  55. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/data/thwip.1 +0 -0
  56. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/detector.py +0 -0
  57. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/endpoints.py +0 -0
  58. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/handoff.py +0 -0
  59. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/limits.py +0 -0
  60. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/shortcuts.py +0 -0
  61. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/__init__.py +0 -0
  62. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/code_runner.py +0 -0
  63. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/file_editor.py +0 -0
  64. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/git_ops.py +0 -0
  65. {thwip_cli-1.9.0 → thwip_cli-1.9.2}/thwip/tools/terminal.py +0 -0
  66. {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.0
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.0. The Python suite
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 |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.9.0"
7
+ version = "1.9.2"
8
8
  description = "Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 == [("offline", "test-model")]
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
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.9.0"
3
+ __version__ = "1.9.2"
4
4
  __app_name__ = "thwip"
@@ -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("\n[dim]Exiting thwip. Goodbye![/dim]")
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 this project's memory.")
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
- print_success(f"Created {memory.path} from the template with detected facts. Edit it or run /memory update.")
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: {written} note{'s' if written != 1 else ''} written or refreshed under {vault.root}.")
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: legacy session has uncounted earlier tool results"),
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
- # Capability comparison & disclaimer
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
- console.print(
1164
- render_capability_disclaimer(
1165
- agent_name=new_agent.display_name,
1166
- company=new_agent.company,
1167
- supported=[c.display_name for c in new_capabilities],
1168
- unsupported=missing,
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
- f"Now chatting with {new_agent.display_name} ({chosen_model}) through its existing sign-in. "
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
- f"Now chatting with {new_agent.display_name} ({chosen_model}). "
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
- content.append(f"Config Key: {self.config.key_sources.get(self.current_agent.name, 'None')}\n", style="dim")
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 [context file]({Path(card.context_path).as_uri()}). "
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
- return sorted(result, key=lambda x: x["updated_at"], reverse=True)
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")
@@ -1022,7 +1022,7 @@ wheels = [
1022
1022
 
1023
1023
  [[package]]
1024
1024
  name = "thwip-cli"
1025
- version = "1.9.0"
1025
+ version = "1.9.2"
1026
1026
  source = { editable = "." }
1027
1027
  dependencies = [
1028
1028
  { name = "anthropic" },
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