thwip-cli 1.6.0__tar.gz → 1.6.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 (63) hide show
  1. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/PKG-INFO +1 -1
  2. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/docs/verification.md +5 -2
  3. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/pyproject.toml +1 -1
  4. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/fake_providers.py +27 -0
  5. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_direct_providers_e2e.py +28 -1
  6. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/__init__.py +1 -1
  7. thwip_cli-1.6.2/thwip/agents/ollama_agent.py +218 -0
  8. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/cli.py +35 -1
  9. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/uv.lock +1 -1
  10. thwip_cli-1.6.0/thwip/agents/ollama_agent.py +0 -212
  11. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/.github/workflows/publish.yml +0 -0
  12. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/.gitignore +0 -0
  13. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/LICENSE +0 -0
  14. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/README.md +0 -0
  15. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/docs/handoff-research.md +0 -0
  16. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/install.sh +0 -0
  17. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/__init__.py +0 -0
  18. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_agents.py +0 -0
  19. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_audit_regressions.py +0 -0
  20. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_catalog.py +0 -0
  21. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_cli.py +0 -0
  22. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_compatible_streaming.py +0 -0
  23. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_config.py +0 -0
  24. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_detector.py +0 -0
  25. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_handoff.py +0 -0
  26. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_native_agents.py +0 -0
  27. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_native_launcher.py +0 -0
  28. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_native_print.py +0 -0
  29. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_native_rpc.py +0 -0
  30. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_parity_commands.py +0 -0
  31. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_repair_verification.py +0 -0
  32. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_session.py +0 -0
  33. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_tools.py +0 -0
  34. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_utils.py +0 -0
  35. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/__main__.py +0 -0
  36. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/__init__.py +0 -0
  37. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/base.py +0 -0
  38. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/catalog.py +0 -0
  39. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/chat_messages.py +0 -0
  40. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/claude_agent.py +0 -0
  41. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/deepseek_agent.py +0 -0
  42. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/google_agent.py +0 -0
  43. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/groq_agent.py +0 -0
  44. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/native_agent.py +0 -0
  45. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/native_common.py +0 -0
  46. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/native_print.py +0 -0
  47. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/native_rpc.py +0 -0
  48. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/openai_agent.py +0 -0
  49. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/openrouter_agent.py +0 -0
  50. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/config.py +0 -0
  51. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/detector.py +0 -0
  52. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/endpoints.py +0 -0
  53. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/handoff.py +0 -0
  54. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/limits.py +0 -0
  55. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/session.py +0 -0
  56. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/shortcuts.py +0 -0
  57. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/theme.py +0 -0
  58. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/__init__.py +0 -0
  59. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/code_runner.py +0 -0
  60. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/file_editor.py +0 -0
  61. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/git_ops.py +0 -0
  62. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/terminal.py +0 -0
  63. {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thwip-cli
3
- Version: 1.6.0
3
+ Version: 1.6.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,7 +1,7 @@
1
1
  # Verification status
2
2
 
3
- Verified locally on 2026-09-24 for v1.6.0. The Python suite
4
- passes 263 offline tests. Exhaustive behavior across every provider and
3
+ Verified locally on 2026-09-24 for v1.6.2. The Python suite
4
+ passes 265 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
 
7
7
  ## Native CLI connections (2026-09-24)
@@ -19,6 +19,9 @@ 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
+ | Terminal hygiene (v1.6.2) | Cursor-position queries disabled, keyboard echo off during turns, stray input flushed before each prompt; fixes `^[`/`^R` noise and phantom empty prompts seen in a real Ghostty session during a slow Antigravity reply. Permission prompt and chat re-verified live afterwards |
23
+ | Ollama adapter (v1.6.1) | End to end against the fake Ollama routes: model list, streamed text with usage, tool call, HTTP 500 and unreachable server now raise clear errors instead of ending the turn silently |
24
+ | Dependency audits (v1.6.1) | pip-audit reports no known vulnerabilities; npm audit reports zero |
22
25
  | Direct API adapters (v1.6.0) | All six (OpenAI, Anthropic, Google, DeepSeek, Groq, OpenRouter) run end to end through their real SDKs over HTTP against `tests/fake_providers.py`: live catalog, streamed text with usage, tool call and result round trip, HTTP 429 to LimitHit. The real REPL was also driven against the fake with direct keys: startup, live `/models`, chat, read_file tool round, provider switches, and 429 failover |
23
26
  | Full command sweep (v1.5.1) | Every slash command with invalid arguments, native launcher decline, key picker cancel, Ctrl+T, Ctrl+C inside pickers and confirmations, Backspace editing; found and fixed Backspace triggering `/history` via the Ctrl+H binding |
24
27
  | Parity commands | Live REPL run: `!git log`, `/model` picker, `@file` mention answered by Codex, `/compact` summary, `/export`, `/copy`, `/diff`, `/new`, `/resume`, `/usage` |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.6.0"
7
+ version = "1.6.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"
@@ -17,6 +17,7 @@ from urllib.parse import urlparse
17
17
  REPLY = "fake reply from local provider"
18
18
  CHAT_MODELS = ["gpt-fake-chat", "gpt-fake-limited"]
19
19
  GEMINI_MODELS = ["gemini-fake-chat", "gemini-fake-limited"]
20
+ OLLAMA_MODELS = ["fake-local:latest", "fake-broken:latest"]
20
21
 
21
22
 
22
23
  def _wants_tool(body: dict) -> bool:
@@ -76,6 +77,8 @@ class Handler(BaseHTTPRequestHandler):
76
77
  "inputTokenLimit": 32000, "outputTokenLimit": 8000} for m in GEMINI_MODELS]})
77
78
  if path.endswith("/models"):
78
79
  return self._json(200, {"data": [{"id": m, "display_name": m, "context_window": 32000} for m in CHAT_MODELS]})
80
+ if path.endswith("/api/tags"):
81
+ return self._json(200, {"models": [{"name": m, "model": m, "size": 1, "details": {"parameter_size": "1B"}} for m in OLLAMA_MODELS]})
79
82
  return self._json(404, {"error": "not found"})
80
83
 
81
84
  def do_POST(self):
@@ -86,6 +89,8 @@ class Handler(BaseHTTPRequestHandler):
86
89
  if _is_limited(body, path):
87
90
  return self._json(429, {"error": {"message": "Rate limit reached for model; quota exhausted", "type": "rate_limit_error"}},
88
91
  {"retry-after": "0"})
92
+ if path.endswith("/api/chat"):
93
+ return self._ollama(body)
89
94
  if path.endswith("/chat/completions"):
90
95
  return self._chat_completions(body)
91
96
  if path.endswith("/responses"):
@@ -130,6 +135,28 @@ class Handler(BaseHTTPRequestHandler):
130
135
  "output": output, "usage": {"input_tokens": 11, "output_tokens": 5, "total_tokens": 16},
131
136
  "parallel_tool_calls": True, "tool_choice": "auto", "tools": []})
132
137
 
138
+ # --- Ollama ---
139
+ def _ollama(self, body):
140
+ if "broken" in str(body.get("model", "")):
141
+ return self._json(500, {"error": "model runner crashed"})
142
+ if _wants_tool(body):
143
+ message = {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "read_file", "arguments": {"file_path": "note.txt"}}}]}
144
+ else:
145
+ message = {"role": "assistant", "content": REPLY}
146
+ final = {"model": body["model"], "message": message, "done": True, "prompt_eval_count": 11, "eval_count": 5}
147
+ if body.get("stream", True):
148
+ self.send_response(200)
149
+ self.send_header("Content-Type", "application/x-ndjson")
150
+ self.end_headers()
151
+ if not _wants_tool(body):
152
+ for piece in ["fake reply ", "from local provider"]:
153
+ self.wfile.write((json.dumps({"model": body["model"], "message": {"role": "assistant", "content": piece}, "done": False}) + "\n").encode())
154
+ final["message"] = {"role": "assistant", "content": ""}
155
+ self.wfile.write((json.dumps(final) + "\n").encode())
156
+ self.wfile.flush()
157
+ return None
158
+ return self._json(200, final)
159
+
133
160
  # --- Anthropic ---
134
161
  def _anthropic(self, body):
135
162
  usage = {"input_tokens": 11, "output_tokens": 5}
@@ -97,7 +97,7 @@ async def test_http_429_becomes_limit_hit(agent):
97
97
 
98
98
 
99
99
  def test_default_urls_are_used_without_override(monkeypatch):
100
- for provider, env in ENV.items():
100
+ for env in ENV.values():
101
101
  monkeypatch.delenv(env, raising=False)
102
102
  from thwip import endpoints
103
103
  endpoints.configure({})
@@ -105,3 +105,30 @@ def test_default_urls_are_used_without_override(monkeypatch):
105
105
  endpoints.configure({"openai": "https://proxy.example/v1/"})
106
106
  assert base_url("openai") == "https://proxy.example/v1" and is_overridden("openai")
107
107
  endpoints.configure({})
108
+
109
+
110
+ @pytest.fixture
111
+ def ollama(fake):
112
+ from thwip.agents.ollama_agent import OllamaAgent
113
+ return OllamaAgent(host=fake.url)
114
+
115
+
116
+ def test_ollama_lists_models_from_server(ollama):
117
+ assert ollama.is_configured()
118
+ assert [m.id for m in ollama.available_models] == ["fake-local:latest", "fake-broken:latest"]
119
+
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_ollama_stream_tool_round_and_errors(ollama):
123
+ events = await run(ollama, messages=[{"role": "user", "content": "hello"}], model="fake-local:latest", stream=True)
124
+ assert "".join(e.content for e in events if isinstance(e, TextDelta)) == REPLY
125
+ assert isinstance(events[-1], AgentDone) and events[-1].usage.input_tokens == 11
126
+ events = await run(ollama, messages=[{"role": "user", "content": "Please read the file note.txt"}], model="fake-local:latest",
127
+ tools=TOOLS, stream=False)
128
+ calls = [e for e in events if isinstance(e, ToolUseStart)]
129
+ assert calls and calls[0].tool_name == "read_file" and calls[0].args == {"file_path": "note.txt"}
130
+ with pytest.raises(RuntimeError, match="HTTP 500"):
131
+ await run(ollama, messages=[{"role": "user", "content": "hello"}], model="fake-broken:latest", stream=True)
132
+ from thwip.agents.ollama_agent import OllamaAgent
133
+ with pytest.raises(RuntimeError, match="unreachable"):
134
+ await run(OllamaAgent(host="http://127.0.0.1:9"), messages=[{"role": "user", "content": "hello"}], model="x", stream=True)
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.6.0"
3
+ __version__ = "1.6.2"
4
4
  __app_name__ = "thwip"
@@ -0,0 +1,218 @@
1
+ """
2
+ Ollama agent adapter.
3
+
4
+ Local offline models with unlimited quota, zero cost, and full privacy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import urllib.error
12
+ import urllib.request
13
+ from collections.abc import AsyncIterator
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from thwip.agents.base import (
19
+ AgentDone,
20
+ AgentEvent,
21
+ BaseAgent,
22
+ Capability,
23
+ LimitStatus,
24
+ ModelInfo,
25
+ SubscriptionInfo,
26
+ SubscriptionTier,
27
+ TextDelta,
28
+ TokenUsage,
29
+ ToolUseStart,
30
+ )
31
+
32
+
33
+ class OllamaAgent(BaseAgent):
34
+ """
35
+ Ollama Local Agent.
36
+
37
+ Runs entirely on-device with zero rate limits and unlimited usage.
38
+ """
39
+
40
+ name = "ollama"
41
+ display_name = "Ollama (Local)"
42
+ company = "Ollama"
43
+ description = "Local models running entirely on-device (zero API limits, private)"
44
+ website = "https://ollama.com"
45
+
46
+ capabilities = {
47
+ Capability.CHAT,
48
+ Capability.FILE_EDIT,
49
+ Capability.FILE_READ,
50
+ Capability.CODE_RUN,
51
+ Capability.TERMINAL,
52
+ Capability.GIT,
53
+ }
54
+
55
+ def __init__(self, host: str = "http://localhost:11434") -> None:
56
+ self.host = host.rstrip("/")
57
+ self._cached_models: list[ModelInfo] | None = None
58
+
59
+ @property
60
+ def available_models(self) -> list[ModelInfo]:
61
+ if self._cached_models is not None:
62
+ return self._cached_models
63
+
64
+ # Try to query running Ollama server for downloaded models
65
+ models = []
66
+ try:
67
+ req = urllib.request.Request(f"{self.host}/api/tags")
68
+ with urllib.request.urlopen(req, timeout=1.5) as resp:
69
+ data = json.loads(resp.read().decode("utf-8"))
70
+ for m in data.get("models", []):
71
+ name = m.get("name", "")
72
+ models.append(
73
+ ModelInfo(
74
+ id=name,
75
+ name=f"{name} (Local)",
76
+ context_window=32_768,
77
+ max_output=8_192,
78
+ supports_tools=True,
79
+ supports_streaming=True,
80
+ pricing_input=0.0,
81
+ pricing_output=0.0,
82
+ )
83
+ )
84
+ except Exception:
85
+ pass
86
+
87
+ if not models:
88
+ # Defaults
89
+ models = [
90
+ ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
91
+ ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
92
+ ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
93
+ ModelInfo(id="codellama", name="CodeLlama"),
94
+ ]
95
+ self._cached_models = models
96
+ return models
97
+
98
+ def is_installed(self) -> bool:
99
+ return shutil.which("ollama") is not None or self._is_server_reachable()
100
+
101
+ def get_handoff_models(self) -> list[ModelInfo]:
102
+ """Never query even a remote configured Ollama host during a preview."""
103
+ if self._cached_models is not None:
104
+ return list(self._cached_models)
105
+ return [
106
+ ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
107
+ ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
108
+ ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
109
+ ModelInfo(id="codellama", name="CodeLlama"),
110
+ ]
111
+
112
+ def _is_server_reachable(self) -> bool:
113
+ try:
114
+ req = urllib.request.Request(f"{self.host}/api/tags")
115
+ with urllib.request.urlopen(req, timeout=1.0) as resp:
116
+ return resp.status == 200
117
+ except Exception:
118
+ return False
119
+
120
+ def is_configured(self) -> bool:
121
+ return self._is_server_reachable()
122
+
123
+ def get_install_info(self) -> dict[str, str]:
124
+ path = shutil.which("ollama") or ""
125
+ return {
126
+ "method": "CLI / Local Daemon" if path else "Local Server",
127
+ "path": path or self.host,
128
+ "version": "Local",
129
+ }
130
+
131
+ def get_subscription_info(self) -> SubscriptionInfo:
132
+ return SubscriptionInfo(
133
+ tier=SubscriptionTier.UNLIMITED,
134
+ is_active=self.is_configured(),
135
+ message="Unlimited offline local compute",
136
+ )
137
+
138
+ async def chat(
139
+ self,
140
+ messages: list[dict[str, Any]],
141
+ model: str | None = None,
142
+ system_prompt: str | None = None,
143
+ tools: list[dict[str, Any]] | None = None,
144
+ stream: bool = True,
145
+ ) -> AsyncIterator[AgentEvent]:
146
+ model = model or self.get_default_model()
147
+ formatted_messages = []
148
+ if system_prompt:
149
+ formatted_messages.append({"role": "system", "content": system_prompt})
150
+ formatted_messages.extend(messages)
151
+
152
+ payload: dict[str, Any] = {
153
+ "model": model,
154
+ "messages": formatted_messages,
155
+ "stream": stream,
156
+ }
157
+ if tools:
158
+ payload["tools"] = tools
159
+
160
+ try:
161
+ async with httpx.AsyncClient(timeout=120.0) as client:
162
+ if stream:
163
+ async with client.stream(
164
+ "POST", f"{self.host}/api/chat", json=payload
165
+ ) as resp:
166
+ if resp.status_code != 200:
167
+ body = (await resp.aread()).decode("utf-8", "replace")[:200]
168
+ raise RuntimeError(f"Ollama returned HTTP {resp.status_code} for {model}: {body or 'no details'}")
169
+ async for line in resp.aiter_lines():
170
+ if not line:
171
+ continue
172
+ try:
173
+ data = json.loads(line)
174
+ msg = data.get("message", {})
175
+ content = msg.get("content", "")
176
+ if content:
177
+ yield TextDelta(content=content)
178
+ for call in msg.get("tool_calls", []):
179
+ function = call.get("function", {})
180
+ yield ToolUseStart(
181
+ tool_id=call.get("id", function.get("name", "tool")),
182
+ tool_name=function.get("name", ""),
183
+ args=function.get("arguments", {}),
184
+ )
185
+ if data.get("done"):
186
+ prompt_eval = data.get("prompt_eval_count", 0)
187
+ eval_count = data.get("eval_count", 0)
188
+ yield AgentDone(
189
+ usage=TokenUsage(
190
+ input_tokens=prompt_eval,
191
+ output_tokens=eval_count,
192
+ )
193
+ )
194
+ except json.JSONDecodeError:
195
+ continue
196
+ else:
197
+ resp = await client.post(f"{self.host}/api/chat", json=payload)
198
+ if resp.status_code != 200:
199
+ raise RuntimeError(f"Ollama returned HTTP {resp.status_code} for {model}: {resp.text[:200] or 'no details'}")
200
+ data = resp.json()
201
+ message = data.get("message", {})
202
+ if message.get("content"):
203
+ yield TextDelta(content=message["content"])
204
+ for call in message.get("tool_calls", []):
205
+ function = call.get("function", {})
206
+ yield ToolUseStart(
207
+ tool_id=call.get("id", function.get("name", "tool")),
208
+ tool_name=function.get("name", ""),
209
+ args=function.get("arguments", {}),
210
+ )
211
+ yield AgentDone(usage=TokenUsage(input_tokens=data.get("prompt_eval_count", 0) or 0,
212
+ output_tokens=data.get("eval_count", 0) or 0))
213
+ except httpx.HTTPError as exc:
214
+ raise RuntimeError(f"Ollama server at {self.host} is unreachable: {type(exc).__name__}. "
215
+ "Start it with `ollama serve` or /switch to another agent.") from exc
216
+
217
+ def check_limits(self) -> LimitStatus:
218
+ return LimitStatus.OK if self.is_configured() else LimitStatus.NO_KEY
@@ -68,6 +68,39 @@ from thwip.theme import (
68
68
  )
69
69
  from thwip.tools import ToolManager
70
70
 
71
+ # prompt_toolkit's cursor-position query is answered by the terminal with an escape sequence.
72
+ # During a long native turn nobody reads stdin, so the answer would be echoed as ^[ ... R and later
73
+ # swallowed as input. The layout does not need it, so disable the query.
74
+ os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1")
75
+
76
+
77
+ class QuietTerminal:
78
+ """Turn off keyboard echo while a turn runs and drop stray input before the next prompt."""
79
+
80
+ def __enter__(self):
81
+ self._saved = None
82
+ try:
83
+ import termios
84
+ self._fd = sys.stdin.fileno()
85
+ if sys.stdin.isatty():
86
+ self._saved = termios.tcgetattr(self._fd)
87
+ quiet = termios.tcgetattr(self._fd)
88
+ quiet[3] &= ~termios.ECHO
89
+ termios.tcsetattr(self._fd, termios.TCSANOW, quiet)
90
+ except (ImportError, OSError, ValueError, AttributeError):
91
+ self._saved = None
92
+ return self
93
+
94
+ def __exit__(self, *exc):
95
+ try:
96
+ import termios
97
+ if self._saved is not None:
98
+ termios.tcflush(self._fd, termios.TCIFLUSH)
99
+ termios.tcsetattr(self._fd, termios.TCSANOW, self._saved)
100
+ except (ImportError, OSError, ValueError, AttributeError):
101
+ pass
102
+ return False
103
+
71
104
 
72
105
  class TurnInterrupted(Exception):
73
106
  """Raised when the user presses Ctrl+C at a prompt shown during a turn."""
@@ -217,7 +250,8 @@ class ThwipCLI:
217
250
  continue
218
251
 
219
252
  # Process chat message with agent; Ctrl+C interrupts the turn, not the REPL.
220
- await self._run_interruptible(self.process_user_message(self._expand_mentions(user_input)))
253
+ with QuietTerminal():
254
+ await self._run_interruptible(self.process_user_message(self._expand_mentions(user_input)))
221
255
 
222
256
  except (KeyboardInterrupt, EOFError):
223
257
  console.print("\n[dim]Exiting thwip. Goodbye![/dim]")
@@ -1022,7 +1022,7 @@ wheels = [
1022
1022
 
1023
1023
  [[package]]
1024
1024
  name = "thwip-cli"
1025
- version = "1.6.0"
1025
+ version = "1.6.2"
1026
1026
  source = { editable = "." }
1027
1027
  dependencies = [
1028
1028
  { name = "anthropic" },
@@ -1,212 +0,0 @@
1
- """
2
- Ollama agent adapter.
3
-
4
- Local offline models with unlimited quota, zero cost, and full privacy.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import json
10
- import shutil
11
- import urllib.error
12
- import urllib.request
13
- from collections.abc import AsyncIterator
14
- from typing import Any
15
-
16
- import httpx
17
-
18
- from thwip.agents.base import (
19
- AgentDone,
20
- AgentEvent,
21
- BaseAgent,
22
- Capability,
23
- LimitStatus,
24
- ModelInfo,
25
- SubscriptionInfo,
26
- SubscriptionTier,
27
- TextDelta,
28
- TokenUsage,
29
- ToolUseStart,
30
- )
31
-
32
-
33
- class OllamaAgent(BaseAgent):
34
- """
35
- Ollama Local Agent.
36
-
37
- Runs entirely on-device with zero rate limits and unlimited usage.
38
- """
39
-
40
- name = "ollama"
41
- display_name = "Ollama (Local)"
42
- company = "Ollama"
43
- description = "Local models running entirely on-device (zero API limits, private)"
44
- website = "https://ollama.com"
45
-
46
- capabilities = {
47
- Capability.CHAT,
48
- Capability.FILE_EDIT,
49
- Capability.FILE_READ,
50
- Capability.CODE_RUN,
51
- Capability.TERMINAL,
52
- Capability.GIT,
53
- }
54
-
55
- def __init__(self, host: str = "http://localhost:11434") -> None:
56
- self.host = host.rstrip("/")
57
- self._cached_models: list[ModelInfo] | None = None
58
-
59
- @property
60
- def available_models(self) -> list[ModelInfo]:
61
- if self._cached_models is not None:
62
- return self._cached_models
63
-
64
- # Try to query running Ollama server for downloaded models
65
- models = []
66
- try:
67
- req = urllib.request.Request(f"{self.host}/api/tags")
68
- with urllib.request.urlopen(req, timeout=1.5) as resp:
69
- data = json.loads(resp.read().decode("utf-8"))
70
- for m in data.get("models", []):
71
- name = m.get("name", "")
72
- models.append(
73
- ModelInfo(
74
- id=name,
75
- name=f"{name} (Local)",
76
- context_window=32_768,
77
- max_output=8_192,
78
- supports_tools=True,
79
- supports_streaming=True,
80
- pricing_input=0.0,
81
- pricing_output=0.0,
82
- )
83
- )
84
- except Exception:
85
- pass
86
-
87
- if not models:
88
- # Defaults
89
- models = [
90
- ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
91
- ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
92
- ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
93
- ModelInfo(id="codellama", name="CodeLlama"),
94
- ]
95
- self._cached_models = models
96
- return models
97
-
98
- def is_installed(self) -> bool:
99
- return shutil.which("ollama") is not None or self._is_server_reachable()
100
-
101
- def get_handoff_models(self) -> list[ModelInfo]:
102
- """Never query even a remote configured Ollama host during a preview."""
103
- if self._cached_models is not None:
104
- return list(self._cached_models)
105
- return [
106
- ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
107
- ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
108
- ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
109
- ModelInfo(id="codellama", name="CodeLlama"),
110
- ]
111
-
112
- def _is_server_reachable(self) -> bool:
113
- try:
114
- req = urllib.request.Request(f"{self.host}/api/tags")
115
- with urllib.request.urlopen(req, timeout=1.0) as resp:
116
- return resp.status == 200
117
- except Exception:
118
- return False
119
-
120
- def is_configured(self) -> bool:
121
- return self._is_server_reachable()
122
-
123
- def get_install_info(self) -> dict[str, str]:
124
- path = shutil.which("ollama") or ""
125
- return {
126
- "method": "CLI / Local Daemon" if path else "Local Server",
127
- "path": path or self.host,
128
- "version": "Local",
129
- }
130
-
131
- def get_subscription_info(self) -> SubscriptionInfo:
132
- return SubscriptionInfo(
133
- tier=SubscriptionTier.UNLIMITED,
134
- is_active=self.is_configured(),
135
- message="Unlimited offline local compute",
136
- )
137
-
138
- async def chat(
139
- self,
140
- messages: list[dict[str, Any]],
141
- model: str | None = None,
142
- system_prompt: str | None = None,
143
- tools: list[dict[str, Any]] | None = None,
144
- stream: bool = True,
145
- ) -> AsyncIterator[AgentEvent]:
146
- model = model or self.get_default_model()
147
- formatted_messages = []
148
- if system_prompt:
149
- formatted_messages.append({"role": "system", "content": system_prompt})
150
- formatted_messages.extend(messages)
151
-
152
- payload: dict[str, Any] = {
153
- "model": model,
154
- "messages": formatted_messages,
155
- "stream": stream,
156
- }
157
- if tools:
158
- payload["tools"] = tools
159
-
160
- async with httpx.AsyncClient(timeout=120.0) as client:
161
- if stream:
162
- async with client.stream(
163
- "POST", f"{self.host}/api/chat", json=payload
164
- ) as resp:
165
- if resp.status_code != 200:
166
- yield AgentDone()
167
- return
168
- async for line in resp.aiter_lines():
169
- if not line:
170
- continue
171
- try:
172
- data = json.loads(line)
173
- msg = data.get("message", {})
174
- content = msg.get("content", "")
175
- if content:
176
- yield TextDelta(content=content)
177
- for call in msg.get("tool_calls", []):
178
- function = call.get("function", {})
179
- yield ToolUseStart(
180
- tool_id=call.get("id", function.get("name", "tool")),
181
- tool_name=function.get("name", ""),
182
- args=function.get("arguments", {}),
183
- )
184
- if data.get("done"):
185
- prompt_eval = data.get("prompt_eval_count", 0)
186
- eval_count = data.get("eval_count", 0)
187
- yield AgentDone(
188
- usage=TokenUsage(
189
- input_tokens=prompt_eval,
190
- output_tokens=eval_count,
191
- )
192
- )
193
- except json.JSONDecodeError:
194
- continue
195
- else:
196
- resp = await client.post(f"{self.host}/api/chat", json=payload)
197
- if resp.status_code == 200:
198
- data = resp.json()
199
- message = data.get("message", {})
200
- if message.get("content"):
201
- yield TextDelta(content=message["content"])
202
- for call in message.get("tool_calls", []):
203
- function = call.get("function", {})
204
- yield ToolUseStart(
205
- tool_id=call.get("id", function.get("name", "tool")),
206
- tool_name=function.get("name", ""),
207
- args=function.get("arguments", {}),
208
- )
209
- yield AgentDone()
210
-
211
- def check_limits(self) -> LimitStatus:
212
- return LimitStatus.OK if self.is_configured() else LimitStatus.NO_KEY
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