tarmina 0.1.0__py3-none-any.whl
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.
- tarmina/__init__.py +13 -0
- tarmina/ai.py +157 -0
- tarmina/app.py +372 -0
- tarmina/cli.py +265 -0
- tarmina/config.py +79 -0
- tarmina/config.yaml +31 -0
- tarmina/memory.py +101 -0
- tarmina/tarmina.css +127 -0
- tarmina/tools/__init__.py +0 -0
- tarmina/tools/system.py +156 -0
- tarmina-0.1.0.dist-info/METADATA +65 -0
- tarmina-0.1.0.dist-info/RECORD +16 -0
- tarmina-0.1.0.dist-info/WHEEL +5 -0
- tarmina-0.1.0.dist-info/entry_points.txt +2 -0
- tarmina-0.1.0.dist-info/licenses/LICENSE +21 -0
- tarmina-0.1.0.dist-info/top_level.txt +1 -0
tarmina/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
if sys.platform == "win32":
|
|
5
|
+
try:
|
|
6
|
+
sys.stdout = io.TextIOWrapper(
|
|
7
|
+
sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True
|
|
8
|
+
)
|
|
9
|
+
sys.stderr = io.TextIOWrapper(
|
|
10
|
+
sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True
|
|
11
|
+
)
|
|
12
|
+
except Exception:
|
|
13
|
+
pass
|
tarmina/ai.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from typing import AsyncGenerator
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class AIConfig:
|
|
7
|
+
provider: str
|
|
8
|
+
model: str
|
|
9
|
+
api_key: str = ""
|
|
10
|
+
base_url: str = ""
|
|
11
|
+
temperature: float = 0.8
|
|
12
|
+
max_tokens: int = 2048
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AIProvider:
|
|
16
|
+
def __init__(self, config: AIConfig):
|
|
17
|
+
self.config = config
|
|
18
|
+
|
|
19
|
+
async def chat(self, messages: list[dict], system_prompt: str = "") -> str:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
async def stream(self, messages: list[dict], system_prompt: str = "") -> AsyncGenerator[str, None]:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenAIProvider(AIProvider):
|
|
27
|
+
def __init__(self, config: AIConfig):
|
|
28
|
+
super().__init__(config)
|
|
29
|
+
from openai import AsyncOpenAI
|
|
30
|
+
kwargs = {"api_key": config.api_key, "max_retries": 2}
|
|
31
|
+
if config.base_url:
|
|
32
|
+
kwargs["base_url"] = config.base_url
|
|
33
|
+
self.client = AsyncOpenAI(**kwargs)
|
|
34
|
+
|
|
35
|
+
def _build(self, messages, system_prompt):
|
|
36
|
+
msgs = []
|
|
37
|
+
if system_prompt:
|
|
38
|
+
msgs.append({"role": "system", "content": system_prompt})
|
|
39
|
+
msgs.extend(messages)
|
|
40
|
+
return msgs
|
|
41
|
+
|
|
42
|
+
async def chat(self, messages, system_prompt=""):
|
|
43
|
+
r = await self.client.chat.completions.create(
|
|
44
|
+
model=self.config.model,
|
|
45
|
+
messages=self._build(messages, system_prompt),
|
|
46
|
+
temperature=self.config.temperature,
|
|
47
|
+
max_tokens=self.config.max_tokens,
|
|
48
|
+
)
|
|
49
|
+
return r.choices[0].message.content or ""
|
|
50
|
+
|
|
51
|
+
async def stream(self, messages, system_prompt=""):
|
|
52
|
+
s = await self.client.chat.completions.create(
|
|
53
|
+
model=self.config.model,
|
|
54
|
+
messages=self._build(messages, system_prompt),
|
|
55
|
+
temperature=self.config.temperature,
|
|
56
|
+
max_tokens=self.config.max_tokens,
|
|
57
|
+
stream=True,
|
|
58
|
+
)
|
|
59
|
+
async for chunk in s:
|
|
60
|
+
if chunk.choices[0].delta.content:
|
|
61
|
+
yield chunk.choices[0].delta.content
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OllamaProvider(AIProvider):
|
|
65
|
+
def __init__(self, config: AIConfig):
|
|
66
|
+
super().__init__(config)
|
|
67
|
+
self.base = config.base_url.rstrip("/") if config.base_url else "http://localhost:11434"
|
|
68
|
+
|
|
69
|
+
def _build(self, messages, system_prompt):
|
|
70
|
+
msgs = []
|
|
71
|
+
if system_prompt:
|
|
72
|
+
msgs.append({"role": "system", "content": system_prompt})
|
|
73
|
+
msgs.extend(messages)
|
|
74
|
+
return msgs
|
|
75
|
+
|
|
76
|
+
async def chat(self, messages, system_prompt=""):
|
|
77
|
+
import aiohttp
|
|
78
|
+
import json
|
|
79
|
+
async with aiohttp.ClientSession() as s:
|
|
80
|
+
async with s.post(f"{self.base}/api/chat", json={
|
|
81
|
+
"model": self.config.model, "messages": self._build(messages, system_prompt),
|
|
82
|
+
"stream": False, "options": {"temperature": self.config.temperature},
|
|
83
|
+
}) as r:
|
|
84
|
+
data = await r.json()
|
|
85
|
+
return data.get("message", {}).get("content", "")
|
|
86
|
+
|
|
87
|
+
async def stream(self, messages, system_prompt=""):
|
|
88
|
+
import aiohttp
|
|
89
|
+
import json
|
|
90
|
+
async with aiohttp.ClientSession() as s:
|
|
91
|
+
async with s.post(f"{self.base}/api/chat", json={
|
|
92
|
+
"model": self.config.model, "messages": self._build(messages, system_prompt),
|
|
93
|
+
"stream": True, "options": {"temperature": self.config.temperature},
|
|
94
|
+
}) as r:
|
|
95
|
+
buf = ""
|
|
96
|
+
async for line in r.content:
|
|
97
|
+
line = line.decode().strip()
|
|
98
|
+
if not line:
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
chunk = json.loads(line)
|
|
102
|
+
content = chunk.get("message", {}).get("content", "")
|
|
103
|
+
if content:
|
|
104
|
+
yield content
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class GroqProvider(AIProvider):
|
|
110
|
+
def __init__(self, config: AIConfig):
|
|
111
|
+
super().__init__(config)
|
|
112
|
+
from groq import AsyncGroq
|
|
113
|
+
self.client = AsyncGroq(api_key=config.api_key)
|
|
114
|
+
|
|
115
|
+
def _build(self, messages, system_prompt):
|
|
116
|
+
msgs = []
|
|
117
|
+
if system_prompt:
|
|
118
|
+
msgs.append({"role": "system", "content": system_prompt})
|
|
119
|
+
msgs.extend(messages)
|
|
120
|
+
return msgs
|
|
121
|
+
|
|
122
|
+
async def chat(self, messages, system_prompt=""):
|
|
123
|
+
r = await self.client.chat.completions.create(
|
|
124
|
+
model=self.config.model,
|
|
125
|
+
messages=self._build(messages, system_prompt),
|
|
126
|
+
temperature=self.config.temperature,
|
|
127
|
+
max_tokens=self.config.max_tokens,
|
|
128
|
+
)
|
|
129
|
+
return r.choices[0].message.content or ""
|
|
130
|
+
|
|
131
|
+
async def stream(self, messages, system_prompt=""):
|
|
132
|
+
s = await self.client.chat.completions.create(
|
|
133
|
+
model=self.config.model,
|
|
134
|
+
messages=self._build(messages, system_prompt),
|
|
135
|
+
temperature=self.config.temperature,
|
|
136
|
+
max_tokens=self.config.max_tokens,
|
|
137
|
+
stream=True,
|
|
138
|
+
)
|
|
139
|
+
async for chunk in s:
|
|
140
|
+
if chunk.choices[0].delta.content:
|
|
141
|
+
yield chunk.choices[0].delta.content
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
PROVIDERS = {
|
|
145
|
+
"openai": OpenAIProvider,
|
|
146
|
+
"openrouter": OpenAIProvider,
|
|
147
|
+
"ollama": OllamaProvider,
|
|
148
|
+
"groq": GroqProvider,
|
|
149
|
+
"custom": OpenAIProvider,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def create_provider(config: AIConfig) -> AIProvider:
|
|
154
|
+
cls = PROVIDERS.get(config.provider)
|
|
155
|
+
if not cls:
|
|
156
|
+
raise ValueError(f"Unknown provider: {config.provider}. Options: {list(PROVIDERS.keys())}")
|
|
157
|
+
return cls(config)
|
tarmina/app.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from textual.app import App, ComposeResult
|
|
4
|
+
from textual.widgets import TextArea, Static, ListView, ListItem, Input
|
|
5
|
+
from textual.containers import Container
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
|
|
8
|
+
from .ai import create_provider, AIConfig
|
|
9
|
+
from .config import get_ai_config, get_personality, get_system_config, get_memory_config
|
|
10
|
+
from .memory import Memory
|
|
11
|
+
from .tools.system import SystemTools, execute_tool_call
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe(text: str) -> str:
|
|
15
|
+
try:
|
|
16
|
+
text.encode("cp1252")
|
|
17
|
+
except UnicodeEncodeError:
|
|
18
|
+
text = text.encode("cp1252", errors="replace").decode("cp1252", errors="replace")
|
|
19
|
+
return text.strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandPalette(Container):
|
|
23
|
+
COMMANDS = [
|
|
24
|
+
("/clear", "Clear chat history"),
|
|
25
|
+
("/exit", "Exit the app"),
|
|
26
|
+
("/help", "Show help"),
|
|
27
|
+
("/newsession", "Start a new session"),
|
|
28
|
+
("/sessions", "List all sessions"),
|
|
29
|
+
("/status", "Show system status"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def __init__(self, on_select):
|
|
33
|
+
super().__init__(id="palette")
|
|
34
|
+
self._on_select = on_select
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
yield ListView(id="palette-list")
|
|
38
|
+
|
|
39
|
+
def on_mount(self):
|
|
40
|
+
self._update_list()
|
|
41
|
+
self.query_one("#palette-list", ListView).focus()
|
|
42
|
+
|
|
43
|
+
def _update_list(self, selected_idx: int = 0):
|
|
44
|
+
lv = self.query_one("#palette-list", ListView)
|
|
45
|
+
lv.clear()
|
|
46
|
+
for i, (cmd, desc) in enumerate(self.COMMANDS):
|
|
47
|
+
mark = "[*]" if i == selected_idx else "[ ]"
|
|
48
|
+
lv.append(ListItem(
|
|
49
|
+
Static(f" {mark} [bold #e5e7eb]{cmd}[/] - [#484f58]{desc}[/]", id="cmd-item")
|
|
50
|
+
))
|
|
51
|
+
lv.index = selected_idx
|
|
52
|
+
|
|
53
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted):
|
|
54
|
+
if event.item and event.item.children:
|
|
55
|
+
self._update_list(event.list_view.index or 0)
|
|
56
|
+
|
|
57
|
+
def on_list_view_selected(self, event: ListView.Selected):
|
|
58
|
+
if event.item is not None:
|
|
59
|
+
idx = event.list_view.index
|
|
60
|
+
if idx is not None and 0 <= idx < len(self.COMMANDS):
|
|
61
|
+
self._on_select(self.COMMANDS[idx][0])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TarminaApp(App):
|
|
65
|
+
CSS_PATH = "tarmina.css"
|
|
66
|
+
|
|
67
|
+
BINDINGS = [
|
|
68
|
+
Binding("escape", "quit", "Quit"),
|
|
69
|
+
Binding("ctrl+l", "clear_chat", "Clear"),
|
|
70
|
+
Binding("ctrl+n", "submit", "Send", show=False),
|
|
71
|
+
Binding("ctrl+c", "ignore_ctrl_c", "Copy", show=False),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def __init__(self):
|
|
75
|
+
super().__init__()
|
|
76
|
+
self.ai_config = AIConfig(**get_ai_config())
|
|
77
|
+
self.personality = get_personality()
|
|
78
|
+
self.memory = Memory(get_memory_config())
|
|
79
|
+
self.tools = SystemTools(get_system_config())
|
|
80
|
+
self.provider = None
|
|
81
|
+
self._thinking = False
|
|
82
|
+
self._lines = []
|
|
83
|
+
self._tokens = 0
|
|
84
|
+
self._cost = 0.0
|
|
85
|
+
|
|
86
|
+
def _init_provider(self):
|
|
87
|
+
try:
|
|
88
|
+
self.provider = create_provider(self.ai_config)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
self._add(f"AI init failed: {_safe(str(e))}")
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
yield TextArea(text="", read_only=True, show_line_numbers=False, id="output")
|
|
94
|
+
self._palette = CommandPalette(on_select=self._on_cmd_select)
|
|
95
|
+
self._palette.display = False
|
|
96
|
+
yield self._palette
|
|
97
|
+
yield TextArea(text="", show_line_numbers=False, id="input")
|
|
98
|
+
with Container(id="sep"):
|
|
99
|
+
yield Static(f"[bold #d97706]{self.memory.session_id}[/] · [bold #e5e7eb]{self.ai_config.model}[/] {self.ai_config.provider}", id="build-info")
|
|
100
|
+
with Container(id="menu"):
|
|
101
|
+
yield Static("", id="usage")
|
|
102
|
+
yield Static("[bold #e5e7eb]Ctrl+N[/] send [bold #e5e7eb]Enter[/] newline [bold #e5e7eb]Esc[/] quit [bold #e5e7eb]Ctrl+L[/] clear", id="shortcuts")
|
|
103
|
+
|
|
104
|
+
def on_mount(self):
|
|
105
|
+
self._init_provider()
|
|
106
|
+
if not self.provider:
|
|
107
|
+
self._add("AI not configured. Run: tarmina --setup")
|
|
108
|
+
self.query_one("#input", TextArea).focus()
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
from pyfiglet import Figlet
|
|
113
|
+
logo = Figlet(font="banner3", width=80).renderText("TARMINA").rstrip()
|
|
114
|
+
except ImportError:
|
|
115
|
+
logo = "Tarmina"
|
|
116
|
+
for line in logo.split("\n"):
|
|
117
|
+
self._lines.append(f" {line}")
|
|
118
|
+
self._lines.append("")
|
|
119
|
+
self._lines.append(f" {self.ai_config.provider} / {self.ai_config.model}")
|
|
120
|
+
self._lines.append(f" session: {self.memory.session_id}")
|
|
121
|
+
self._lines.append(f" {self.memory.count()} messages")
|
|
122
|
+
self._lines.append(" " + "\u2500" * 78)
|
|
123
|
+
self._flush()
|
|
124
|
+
self.query_one("#input", TextArea).focus()
|
|
125
|
+
|
|
126
|
+
def on_text_area_changed(self, event: TextArea.Changed):
|
|
127
|
+
if event.text_area.id != "input":
|
|
128
|
+
return
|
|
129
|
+
text = event.text_area.text
|
|
130
|
+
if text == "/":
|
|
131
|
+
self._palette.display = True
|
|
132
|
+
self._palette.query_one("#palette-list", ListView).focus()
|
|
133
|
+
self._palette._update_list()
|
|
134
|
+
elif self._palette.display and text != "/":
|
|
135
|
+
self._palette.display = False
|
|
136
|
+
self.query_one("#input", TextArea).focus()
|
|
137
|
+
|
|
138
|
+
def _on_cmd_select(self, cmd: str):
|
|
139
|
+
self._palette.display = False
|
|
140
|
+
self._handle_command(cmd)
|
|
141
|
+
self.query_one("#input", TextArea).text = ""
|
|
142
|
+
self.query_one("#input", TextArea).focus()
|
|
143
|
+
|
|
144
|
+
def action_submit(self):
|
|
145
|
+
if self._thinking:
|
|
146
|
+
return
|
|
147
|
+
inp = self.query_one("#input", TextArea)
|
|
148
|
+
text = inp.text.strip()
|
|
149
|
+
inp.text = ""
|
|
150
|
+
if not text:
|
|
151
|
+
return
|
|
152
|
+
if text.startswith("/"):
|
|
153
|
+
self._handle_command(text)
|
|
154
|
+
return
|
|
155
|
+
self._lines.append("")
|
|
156
|
+
self._lines.append(f" \u25b8 {_safe(text)}")
|
|
157
|
+
self._lines.append("")
|
|
158
|
+
self._lines.append(" Thinking...")
|
|
159
|
+
self._flush()
|
|
160
|
+
self._thinking = True
|
|
161
|
+
self._think_start = time.time()
|
|
162
|
+
self.run_worker(self._chat(text))
|
|
163
|
+
self.run_worker(self._thinking_timer())
|
|
164
|
+
|
|
165
|
+
def _add(self, text: str):
|
|
166
|
+
self._lines.append(f" {text}")
|
|
167
|
+
self._flush()
|
|
168
|
+
|
|
169
|
+
def _flush(self):
|
|
170
|
+
out = self.query_one("#output", TextArea)
|
|
171
|
+
out.text = "\n".join(self._lines[-500:])
|
|
172
|
+
out.scroll_end(animate=False)
|
|
173
|
+
|
|
174
|
+
async def _thinking_timer(self):
|
|
175
|
+
import asyncio as _asyncio
|
|
176
|
+
while self._thinking:
|
|
177
|
+
elapsed = time.time() - self._think_start
|
|
178
|
+
if self._lines and "Thinking" in self._lines[-1]:
|
|
179
|
+
self._lines[-1] = f" Thinking... {elapsed:.1f}s"
|
|
180
|
+
self._flush()
|
|
181
|
+
await _asyncio.sleep(0.5)
|
|
182
|
+
|
|
183
|
+
def _parse_tool_call(self, text: str) -> dict | None:
|
|
184
|
+
import json as _json
|
|
185
|
+
idx = text.find('"tool"')
|
|
186
|
+
if idx == -1:
|
|
187
|
+
return None
|
|
188
|
+
start = text.rfind("{", 0, idx)
|
|
189
|
+
if start == -1:
|
|
190
|
+
return None
|
|
191
|
+
depth = 0
|
|
192
|
+
for i in range(start, len(text)):
|
|
193
|
+
if text[i] == "{":
|
|
194
|
+
depth += 1
|
|
195
|
+
elif text[i] == "}":
|
|
196
|
+
depth -= 1
|
|
197
|
+
if depth == 0:
|
|
198
|
+
try:
|
|
199
|
+
obj = _json.loads(text[start:i+1])
|
|
200
|
+
if "tool" in obj and "args" in obj:
|
|
201
|
+
return obj
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
return None
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
async def _handle_tool(self, tool_call: dict) -> dict:
|
|
208
|
+
self._add(f"[tool] {tool_call['tool']}({tool_call['args']})")
|
|
209
|
+
self._flush()
|
|
210
|
+
result = await execute_tool_call(tool_call, self.tools)
|
|
211
|
+
if "error" in result:
|
|
212
|
+
self._add(f" error: {result['error'][:200]}")
|
|
213
|
+
elif "message" in result:
|
|
214
|
+
self._add(f" {result['message']}")
|
|
215
|
+
self._flush()
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
async def _chat(self, text: str):
|
|
219
|
+
start = time.time()
|
|
220
|
+
sp = self.personality.get("system_prompt", "")
|
|
221
|
+
if self.tools.allow_commands or self.tools.allow_apps:
|
|
222
|
+
sp += "\n\n" + self.tools.get_tools_prompt()
|
|
223
|
+
|
|
224
|
+
msgs = self.memory.get_messages(limit=40)
|
|
225
|
+
msgs.append({"role": "user", "content": text})
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
full = ""
|
|
229
|
+
async for chunk in self.provider.stream(msgs, sp):
|
|
230
|
+
full += chunk
|
|
231
|
+
|
|
232
|
+
self.memory.add("user", text)
|
|
233
|
+
|
|
234
|
+
tool_call = self._parse_tool_call(full)
|
|
235
|
+
if tool_call:
|
|
236
|
+
result = await self._handle_tool(tool_call)
|
|
237
|
+
result_text = _safe(str(result.get("stdout", result.get("content", result.get("message", str(result))))))
|
|
238
|
+
msgs.append({"role": "assistant", "content": full})
|
|
239
|
+
msgs.append({"role": "user", "content": f"Tool result: {result_text}"})
|
|
240
|
+
await self._tool_loop(msgs, sp, start)
|
|
241
|
+
else:
|
|
242
|
+
self._display_response(full)
|
|
243
|
+
|
|
244
|
+
self._lines.append(f" Thought: {(time.time() - start):.1f}s")
|
|
245
|
+
self._lines.append("")
|
|
246
|
+
self._flush()
|
|
247
|
+
|
|
248
|
+
total_chars = sum(len(m["content"]) for m in msgs) + len(full)
|
|
249
|
+
self._tokens += total_chars // 4
|
|
250
|
+
self._cost += total_chars * 0.0000001
|
|
251
|
+
self._update_usage()
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self._add(f"Error: {_safe(str(e)[:200])}")
|
|
254
|
+
|
|
255
|
+
self._thinking = False
|
|
256
|
+
|
|
257
|
+
async def _tool_loop(self, msgs: list, sp: str, start: float):
|
|
258
|
+
for _ in range(5):
|
|
259
|
+
full = ""
|
|
260
|
+
async for chunk in self.provider.stream(msgs, sp):
|
|
261
|
+
full += chunk
|
|
262
|
+
|
|
263
|
+
tool_call = self._parse_tool_call(full)
|
|
264
|
+
if not tool_call:
|
|
265
|
+
self._display_response(full)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
result = await self._handle_tool(tool_call)
|
|
269
|
+
result_text = _safe(str(result.get("stdout", result.get("content", result.get("message", str(result))))))
|
|
270
|
+
msgs.append({"role": "assistant", "content": full})
|
|
271
|
+
msgs.append({"role": "user", "content": f"Tool result: {result_text}"})
|
|
272
|
+
|
|
273
|
+
self._display_response(full)
|
|
274
|
+
|
|
275
|
+
def _display_response(self, response: str):
|
|
276
|
+
if self._lines and "Thinking..." in self._lines[-1]:
|
|
277
|
+
self._lines.pop()
|
|
278
|
+
if self._lines and self._lines[-1].strip() == "":
|
|
279
|
+
self._lines.pop()
|
|
280
|
+
self._lines.append("")
|
|
281
|
+
for line in _safe(response).split("\n"):
|
|
282
|
+
self._lines.append(f" {line}")
|
|
283
|
+
self._lines.append("")
|
|
284
|
+
self._flush()
|
|
285
|
+
self.memory.add("assistant", _safe(response))
|
|
286
|
+
|
|
287
|
+
def _update_usage(self):
|
|
288
|
+
try:
|
|
289
|
+
w = self.query_one("#usage", Static)
|
|
290
|
+
if self._tokens >= 1000:
|
|
291
|
+
ts = f"{self._tokens/1000:.1f}K"
|
|
292
|
+
else:
|
|
293
|
+
ts = str(self._tokens)
|
|
294
|
+
pct = min(self._tokens * 100 / 200000, 99)
|
|
295
|
+
w.update(f"[#484f58]{ts} ([#e5e7eb]{pct:.0f}%[/]) \xb7 [/#484f58]${self._cost:.2f}")
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
def _handle_command(self, cmd: str):
|
|
300
|
+
parts = cmd.split(maxsplit=1)
|
|
301
|
+
name = parts[0].lower()
|
|
302
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
303
|
+
|
|
304
|
+
if name in ("/exit", "/quit"):
|
|
305
|
+
self.exit()
|
|
306
|
+
elif name == "/clear":
|
|
307
|
+
self.memory.clear()
|
|
308
|
+
self._lines.clear()
|
|
309
|
+
self._flush()
|
|
310
|
+
elif name == "/help":
|
|
311
|
+
self._add("/help /clear /status /exit /sessions /session /newsession")
|
|
312
|
+
elif name == "/sessions":
|
|
313
|
+
all_sessions = Memory.list_sessions()
|
|
314
|
+
if not all_sessions:
|
|
315
|
+
self._add("No saved sessions.")
|
|
316
|
+
return
|
|
317
|
+
current = self.memory.session_id
|
|
318
|
+
for s in all_sessions:
|
|
319
|
+
marker = ">" if s["id"] == current else " "
|
|
320
|
+
self._add(f"{marker} {s['id']} ({s['count']} msgs) {s['preview'][:50]}")
|
|
321
|
+
elif name == "/session":
|
|
322
|
+
if not arg:
|
|
323
|
+
self._add(f"Current session: {self.memory.session_id}")
|
|
324
|
+
return
|
|
325
|
+
if self.memory.switch_session(arg):
|
|
326
|
+
self._lines.clear()
|
|
327
|
+
self._add(f"Switched to session: {arg}")
|
|
328
|
+
self._flush()
|
|
329
|
+
try:
|
|
330
|
+
w = self.query_one("#build-info", Static)
|
|
331
|
+
w.update(f"[bold #d97706]{self.memory.session_id}[/] · [bold #e5e7eb]{self.ai_config.model}[/] {self.ai_config.provider}")
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
else:
|
|
335
|
+
self._add(f"Session not found: {arg}")
|
|
336
|
+
elif name == "/newsession":
|
|
337
|
+
old_id = self.memory.session_id
|
|
338
|
+
self.memory = Memory(get_memory_config())
|
|
339
|
+
self._lines.clear()
|
|
340
|
+
self._add(f"New session: {self.memory.session_id} (was {old_id})")
|
|
341
|
+
self._flush()
|
|
342
|
+
try:
|
|
343
|
+
w = self.query_one("#build-info", Static)
|
|
344
|
+
w.update(f"[bold #d97706]{self.memory.session_id}[/] · [bold #e5e7eb]{self.ai_config.model}[/] {self.ai_config.provider}")
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
elif name == "/save":
|
|
348
|
+
self._add(f"Session {self.memory.session_id} ({self.memory.count()} msgs)")
|
|
349
|
+
elif name == "/status":
|
|
350
|
+
try:
|
|
351
|
+
import psutil
|
|
352
|
+
self._add(f"CPU {psutil.cpu_percent()}% RAM {psutil.virtual_memory().percent}% Disk {psutil.disk_usage('/').percent}%")
|
|
353
|
+
except ImportError:
|
|
354
|
+
self._add(f"{self.ai_config.provider} / {self.ai_config.model}")
|
|
355
|
+
else:
|
|
356
|
+
self._add(f"Unknown: {name}")
|
|
357
|
+
|
|
358
|
+
def action_ignore_ctrl_c(self):
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
def action_quit(self):
|
|
362
|
+
if self._palette.display:
|
|
363
|
+
self._palette.display = False
|
|
364
|
+
self.query_one("#input", TextArea).text = ""
|
|
365
|
+
self.query_one("#input", TextArea).focus()
|
|
366
|
+
return
|
|
367
|
+
self.exit()
|
|
368
|
+
|
|
369
|
+
def action_clear_chat(self):
|
|
370
|
+
self._lines.clear()
|
|
371
|
+
self._flush()
|
|
372
|
+
self.memory.clear()
|