claudetan 1.0.0__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.
- claudetan-1.0.0/PKG-INFO +23 -0
- claudetan-1.0.0/claudetan/__init__.py +0 -0
- claudetan-1.0.0/claudetan/__main__.py +4 -0
- claudetan-1.0.0/claudetan/app.py +310 -0
- claudetan-1.0.0/claudetan/avatar.py +299 -0
- claudetan-1.0.0/claudetan/config.py +41 -0
- claudetan-1.0.0/claudetan/config.toml +14 -0
- claudetan-1.0.0/claudetan/console.py +18 -0
- claudetan-1.0.0/claudetan/session.py +169 -0
- claudetan-1.0.0/claudetan/setup.py +51 -0
- claudetan-1.0.0/claudetan/tray.py +65 -0
- claudetan-1.0.0/claudetan/voice.py +108 -0
- claudetan-1.0.0/pyproject.toml +114 -0
- claudetan-1.0.0/pyproject.toml.orig +106 -0
claudetan-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: claudetan
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Voice-driven Claude Code assistant with an anime avatar overlay
|
|
5
|
+
Keywords: claude,claude-code,voice-assistant,avatar,overlay,whisper,pyside6
|
|
6
|
+
Author: Stoupy51
|
|
7
|
+
Author-email: Stoupy51 <stoupy51@gmail.com>
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Dist: faster-whisper>=1.2
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: pyside6>=6.9
|
|
16
|
+
Requires-Dist: pyte>=0.8.2
|
|
17
|
+
Requires-Dist: pywinpty>=2.0
|
|
18
|
+
Requires-Dist: sounddevice
|
|
19
|
+
Requires-Dist: soundfile
|
|
20
|
+
Requires-Dist: stouputils>=26.10.1
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Project-URL: Issues, https://github.com/Stoupy51/ClaudeTan/issues
|
|
23
|
+
Project-URL: Repository, https://github.com/Stoupy51/ClaudeTan
|
|
File without changes
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
""" ClaudeTan: Claude-tan reacting to what she hears and to the hidden Claude Code session, through its hook events. """
|
|
2
|
+
# Imports
|
|
3
|
+
import logging
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from collections import deque
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import stouputils as stp
|
|
13
|
+
from PySide6.QtCore import QObject, QPoint, QTimer, Signal
|
|
14
|
+
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
|
15
|
+
from PySide6.QtWidgets import QApplication
|
|
16
|
+
|
|
17
|
+
from claudetan.avatar import Avatar, Pill, chime, shorten
|
|
18
|
+
from claudetan.config import APP_DATA, LOG_FILE, Config
|
|
19
|
+
from claudetan.console import enable_colors
|
|
20
|
+
from claudetan.session import HookServer, JsonDict, Session
|
|
21
|
+
from claudetan.tray import Tray
|
|
22
|
+
from claudetan.voice import FOLLOW_UP_S, Listener, microphone
|
|
23
|
+
|
|
24
|
+
# Constants
|
|
25
|
+
LOG: logging.Logger = logging.getLogger("claudetan")
|
|
26
|
+
""" The app's own log, kept at INFO while the libraries stay at WARNING. """
|
|
27
|
+
WAKE_NOTES: tuple[float, ...] = (880.0, 1318.5)
|
|
28
|
+
DONE_NOTES: tuple[float, ...] = (1046.5, 1318.5, 1568.0)
|
|
29
|
+
LONG_TASK_MS: int = 20_000
|
|
30
|
+
""" How long a turn runs before she shrinks to the pill. """
|
|
31
|
+
HEARD_MS: int = 4000
|
|
32
|
+
""" How long the sentence she understood stays up when the session does not start on it right away. """
|
|
33
|
+
STARTUP_S: float = 60.0
|
|
34
|
+
""" How long the session and Whisper may take to start before the app reports them as failed. """
|
|
35
|
+
INSTANCE_NAME: str = "ClaudeTan"
|
|
36
|
+
""" Local server of the running app: a new one connects to it to have it quit. """
|
|
37
|
+
READY: str = "ready\n"
|
|
38
|
+
""" Last line the background app writes to `main`, which returns on it: the venv's launcher keeps the pipe open after it. """
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Classes
|
|
42
|
+
class Assistant(QObject):
|
|
43
|
+
""" What Claude-tan shows at each step of a request, and when the next command may be typed into the session. """
|
|
44
|
+
send = Signal(str)
|
|
45
|
+
""" A command to type, emitted only between turns: keys typed while a dialog is open would answer it. """
|
|
46
|
+
|
|
47
|
+
def __init__(self, avatar: Avatar, pill: Pill) -> None:
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.avatar = avatar
|
|
50
|
+
self.pill = pill
|
|
51
|
+
self.commands: deque[str] = deque()
|
|
52
|
+
self.busy = False
|
|
53
|
+
self.agents: set[str] = set()
|
|
54
|
+
self.asking = False
|
|
55
|
+
self.worked = False
|
|
56
|
+
""" Whether the turn used a tool, which makes her present the result rather than smile. """
|
|
57
|
+
self.activity = ""
|
|
58
|
+
self.message_id = ""
|
|
59
|
+
self.message = ""
|
|
60
|
+
|
|
61
|
+
self.long_task = QTimer(self)
|
|
62
|
+
self.long_task.setSingleShot(True)
|
|
63
|
+
self.long_task.setInterval(LONG_TASK_MS)
|
|
64
|
+
self.long_task.timeout.connect(self.shrink)
|
|
65
|
+
self.dismiss = QTimer(self)
|
|
66
|
+
self.dismiss.setSingleShot(True)
|
|
67
|
+
self.dismiss.timeout.connect(self.leave)
|
|
68
|
+
|
|
69
|
+
def on_wake(self) -> None:
|
|
70
|
+
chime(*WAKE_NOTES)
|
|
71
|
+
self.appear("hello", bubble="Oui ?")
|
|
72
|
+
self.dismiss.start(round(FOLLOW_UP_S * 1000))
|
|
73
|
+
|
|
74
|
+
def on_command(self, text: str) -> None:
|
|
75
|
+
LOG.info("Heard: %s", text)
|
|
76
|
+
self.appear("idle", bubble=f"« {text} »" + (" Je m'en occupe juste après." if self.busy else ""))
|
|
77
|
+
self.dismiss.start(HEARD_MS)
|
|
78
|
+
self.commands.append(text)
|
|
79
|
+
self.flush()
|
|
80
|
+
|
|
81
|
+
def on_hook(self, event: JsonDict) -> None:
|
|
82
|
+
match event["hook_event_name"]:
|
|
83
|
+
case "UserPromptSubmit":
|
|
84
|
+
self.on_prompt(event["prompt"])
|
|
85
|
+
case "PreToolUse":
|
|
86
|
+
self.on_tool(describe_tool(event["tool_name"], event["tool_input"]))
|
|
87
|
+
case "PostToolUse" if self.asking:
|
|
88
|
+
self.settle_permission()
|
|
89
|
+
self.appear("thanks", bubble="Merci !")
|
|
90
|
+
case "PermissionRequest":
|
|
91
|
+
self.on_permission(describe_tool(event["tool_name"], event["tool_input"]))
|
|
92
|
+
case "Notification" if event["notification_type"] == "permission_prompt":
|
|
93
|
+
self.on_permission(event["message"])
|
|
94
|
+
case "MessageDisplay":
|
|
95
|
+
self.on_text(event["message_id"], event["delta"], streaming=not event["final"])
|
|
96
|
+
case "SubagentStart":
|
|
97
|
+
self.agents.add(event["agent_id"])
|
|
98
|
+
self.shrink()
|
|
99
|
+
case "SubagentStop":
|
|
100
|
+
self.agents.discard(event["agent_id"])
|
|
101
|
+
case "Stop":
|
|
102
|
+
self.on_stop(event["last_assistant_message"])
|
|
103
|
+
case "StopFailure":
|
|
104
|
+
self.on_failure(event["error"])
|
|
105
|
+
case _:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def on_prompt(self, prompt: str) -> None:
|
|
109
|
+
""" A turn starts, typed by the app, sent from the Remote Control, or started by Claude Code itself. """
|
|
110
|
+
LOG.info("Prompt: %s", prompt)
|
|
111
|
+
self.busy = True
|
|
112
|
+
self.worked = False
|
|
113
|
+
self.activity = ""
|
|
114
|
+
self.dismiss.stop()
|
|
115
|
+
self.long_task.start()
|
|
116
|
+
if not self.pill.isVisible():
|
|
117
|
+
self.appear("point", bubble=prompt)
|
|
118
|
+
|
|
119
|
+
def on_tool(self, activity: str) -> None:
|
|
120
|
+
LOG.info("Action: %s", activity)
|
|
121
|
+
self.worked = True
|
|
122
|
+
self.settle_permission()
|
|
123
|
+
if activity != self.activity and not self.pill.isVisible():
|
|
124
|
+
self.activity = activity
|
|
125
|
+
self.appear("point", bubble=activity)
|
|
126
|
+
|
|
127
|
+
def on_permission(self, request: str) -> None:
|
|
128
|
+
if self.asking:
|
|
129
|
+
return
|
|
130
|
+
LOG.info("Permission asked: %s", request)
|
|
131
|
+
self.asking = True
|
|
132
|
+
self.long_task.stop()
|
|
133
|
+
self.appear("please", bubble=f"J'ai besoin de ton accord : {request}. Clique sur moi pour répondre.")
|
|
134
|
+
|
|
135
|
+
def settle_permission(self) -> None:
|
|
136
|
+
""" Closes a pending permission prompt, allowed or not, and restarts the countdown to the pill. """
|
|
137
|
+
if self.asking:
|
|
138
|
+
self.asking = False
|
|
139
|
+
self.long_task.start()
|
|
140
|
+
|
|
141
|
+
def on_text(self, message_id: str, delta: str, *, streaming: bool) -> None:
|
|
142
|
+
self.message = self.message + delta if message_id == self.message_id else delta
|
|
143
|
+
self.message_id = message_id
|
|
144
|
+
if not self.pill.isVisible():
|
|
145
|
+
self.appear("idle", bubble=self.message, talking=streaming)
|
|
146
|
+
|
|
147
|
+
def on_stop(self, reply: str) -> None:
|
|
148
|
+
LOG.info("Reply: %s", reply)
|
|
149
|
+
self.end_turn()
|
|
150
|
+
if not self.agents:
|
|
151
|
+
chime(*DONE_NOTES)
|
|
152
|
+
self.appear("present" if self.worked else "happy", bubble=reply)
|
|
153
|
+
self.dismiss.start(reading_ms(reply))
|
|
154
|
+
self.flush()
|
|
155
|
+
|
|
156
|
+
def on_failure(self, error: str) -> None:
|
|
157
|
+
LOG.warning("Request failed: %s", error)
|
|
158
|
+
self.end_turn()
|
|
159
|
+
self.appear("surprised", bubble=f"Oups, la requête a échoué ({error}).")
|
|
160
|
+
self.dismiss.start(reading_ms(error))
|
|
161
|
+
self.flush()
|
|
162
|
+
|
|
163
|
+
def end_turn(self) -> None:
|
|
164
|
+
self.busy = False
|
|
165
|
+
self.asking = False
|
|
166
|
+
self.long_task.stop()
|
|
167
|
+
|
|
168
|
+
def flush(self) -> None:
|
|
169
|
+
if self.commands and not self.busy:
|
|
170
|
+
self.send.emit(self.commands.popleft())
|
|
171
|
+
|
|
172
|
+
def appear(self, expression: str, bubble: str, *, talking: bool = False) -> None:
|
|
173
|
+
self.pill.hide()
|
|
174
|
+
self.avatar.set_expression(expression, talking=talking, bubble=bubble)
|
|
175
|
+
self.avatar.pop_in()
|
|
176
|
+
|
|
177
|
+
def shrink(self) -> None:
|
|
178
|
+
self.avatar.pop_out()
|
|
179
|
+
self.pill.show_at(self.avatar.home() + QPoint(self.avatar.width(), self.avatar.height()))
|
|
180
|
+
|
|
181
|
+
def leave(self) -> None:
|
|
182
|
+
""" Out of the way once nothing needs her: the pill while work goes on, hidden otherwise. """
|
|
183
|
+
if self.busy or self.agents:
|
|
184
|
+
self.shrink()
|
|
185
|
+
return
|
|
186
|
+
self.pill.hide()
|
|
187
|
+
self.avatar.pop_out()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# Functions
|
|
191
|
+
def describe_tool(name: str, tool_input: JsonDict) -> str:
|
|
192
|
+
""" What a tool call does, in a few words for the bubble.
|
|
193
|
+
|
|
194
|
+
>>> describe_tool("Edit", {"file_path": "D:/repo/claudetan/app.py", "old_string": "a", "new_string": "b"})
|
|
195
|
+
'modifie app.py'
|
|
196
|
+
>>> describe_tool("Bash", {"command": "git status", "description": "Show working tree status"})
|
|
197
|
+
'Show working tree status'
|
|
198
|
+
>>> describe_tool("mcp__claudetan__screenshot", {"screen": 2})
|
|
199
|
+
'screenshot'
|
|
200
|
+
"""
|
|
201
|
+
match name:
|
|
202
|
+
case "Read":
|
|
203
|
+
return f"lit {Path(tool_input['file_path']).name}"
|
|
204
|
+
case "Write":
|
|
205
|
+
return f"écrit {Path(tool_input['file_path']).name}"
|
|
206
|
+
case "Edit" | "MultiEdit":
|
|
207
|
+
return f"modifie {Path(tool_input['file_path']).name}"
|
|
208
|
+
case "Bash" | "PowerShell":
|
|
209
|
+
return tool_input.get("description") or f"lance {tool_input['command'][:60]}"
|
|
210
|
+
case "Grep" | "Glob":
|
|
211
|
+
return f"cherche {tool_input['pattern']}"
|
|
212
|
+
case "WebSearch":
|
|
213
|
+
return f"cherche « {tool_input['query']} » sur le web"
|
|
214
|
+
case "WebFetch":
|
|
215
|
+
return f"lit {urlparse(str(tool_input['url'])).netloc}"
|
|
216
|
+
case "Agent" | "Task":
|
|
217
|
+
return f"confie une tâche : {tool_input['description']}"
|
|
218
|
+
case "AskUserQuestion":
|
|
219
|
+
return tool_input["questions"][0]["question"]
|
|
220
|
+
case _:
|
|
221
|
+
return name.rsplit("__", 1)[-1]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def reading_ms(text: str) -> int:
|
|
225
|
+
""" How long a text stays up: 4 s, plus 50 ms per character the bubble shows, 20 s at most. """
|
|
226
|
+
return min(4000 + 50 * len(shorten(text)), 20_000)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main() -> None:
|
|
230
|
+
""" Starts the app in the background and returns once it reports it is ready, so no console window stays open. """
|
|
231
|
+
enable_colors()
|
|
232
|
+
stp.progress("Starting ClaudeTan, which takes about 15 s...")
|
|
233
|
+
# A gui-script would not do: uv makes the venv's pythonw.exe a copy of the console python.exe, which opens a window
|
|
234
|
+
app = subprocess.Popen(
|
|
235
|
+
[sys.executable, "-m", "claudetan.app"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
236
|
+
text=True, creationflags=subprocess.CREATE_NO_WINDOW,
|
|
237
|
+
)
|
|
238
|
+
assert app.stdout
|
|
239
|
+
for line in app.stdout:
|
|
240
|
+
if line == READY:
|
|
241
|
+
stp.info('Say "Hey Claude, ..." to ask her something, and quit her from her tray icon.')
|
|
242
|
+
return
|
|
243
|
+
print(line, end="", flush=True)
|
|
244
|
+
stp.error(f"ClaudeTan stopped while starting, see {stp.CYAN}{LOG_FILE}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def report_ready(session: Session, listener: Listener) -> None:
|
|
248
|
+
""" Tells `main`, through stdout, how the session and Whisper started. """
|
|
249
|
+
if session.started.wait(STARTUP_S):
|
|
250
|
+
stp.info(f"Claude Code session {'resumed' if session.id else 'started'}: {stp.CYAN}{session.url}")
|
|
251
|
+
stp.info(f"Plan usage: {session.plan_usage()}")
|
|
252
|
+
else:
|
|
253
|
+
stp.warning("Claude Code did not start Remote Control: run uv run claudetan-setup, then start the app again", file=sys.stdout)
|
|
254
|
+
if listener.loaded.wait(STARTUP_S):
|
|
255
|
+
stp.info("Whisper runs on the GPU")
|
|
256
|
+
else:
|
|
257
|
+
stp.warning("Whisper did not load", file=sys.stdout)
|
|
258
|
+
stp.info(f"Log: {stp.CYAN}{LOG_FILE}")
|
|
259
|
+
print(READY, end="", flush=True)
|
|
260
|
+
|
|
261
|
+
def replace_running_app() -> None:
|
|
262
|
+
""" Has the app already running quit, and waits until it has, so that starting the app again applies new code and settings. """
|
|
263
|
+
running = QLocalSocket()
|
|
264
|
+
running.connectToServer(INSTANCE_NAME)
|
|
265
|
+
if running.waitForConnected(1000):
|
|
266
|
+
running.waitForDisconnected(30_000)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def run() -> None:
|
|
270
|
+
APP_DATA.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
logging.basicConfig(
|
|
272
|
+
filename=LOG_FILE, encoding="utf-8", level=logging.WARNING, format="%(asctime)s %(levelname)s %(message)s",
|
|
273
|
+
)
|
|
274
|
+
LOG.setLevel(logging.INFO)
|
|
275
|
+
# Without a console the default hooks print tracebacks nowhere
|
|
276
|
+
sys.excepthook = lambda _kind, exception, _trace: logging.critical("Uncaught error", exc_info=exception)
|
|
277
|
+
threading.excepthook = lambda hook: logging.critical("Uncaught error in %s", hook.thread, exc_info=hook.exc_value)
|
|
278
|
+
# Qt's event loop never hands control back to Python's KeyboardInterrupt, so let Ctrl+C end the process directly
|
|
279
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
280
|
+
|
|
281
|
+
app = QApplication(sys.argv)
|
|
282
|
+
app.setQuitOnLastWindowClosed(False)
|
|
283
|
+
replace_running_app()
|
|
284
|
+
instance = QLocalServer()
|
|
285
|
+
instance.listen(INSTANCE_NAME)
|
|
286
|
+
instance.newConnection.connect(app.quit)
|
|
287
|
+
config = Config.load()
|
|
288
|
+
avatar, pill = Avatar(), Pill()
|
|
289
|
+
assistant = Assistant(avatar, pill)
|
|
290
|
+
tray = Tray()
|
|
291
|
+
tray.show()
|
|
292
|
+
hooks = HookServer()
|
|
293
|
+
session = Session(hooks.url, config.prompt)
|
|
294
|
+
for clicked in (avatar.clicked, pill.clicked, tray.open_session):
|
|
295
|
+
clicked.connect(session.open)
|
|
296
|
+
hooks.posted.connect(session.on_hook)
|
|
297
|
+
hooks.posted.connect(assistant.on_hook)
|
|
298
|
+
assistant.send.connect(session.send)
|
|
299
|
+
app.aboutToQuit.connect(session.close)
|
|
300
|
+
listener = Listener(config.language, config.wake)
|
|
301
|
+
listener.woke.connect(assistant.on_wake)
|
|
302
|
+
listener.heard.connect(assistant.on_command)
|
|
303
|
+
threading.Thread(target=listener.run, args=(microphone(),), daemon=True).start()
|
|
304
|
+
report_ready(session, listener)
|
|
305
|
+
sys.exit(app.exec())
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
run()
|
|
310
|
+
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
""" Claude-tan on screen: the sprite overlay with her speech bubble, the pill she shrinks to during long tasks, and her chimes.
|
|
2
|
+
|
|
3
|
+
A click on her or on the pill opens the Remote Control session, a long press on her lets you drag her somewhere else.
|
|
4
|
+
"""
|
|
5
|
+
# Imports
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import override
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import sounddevice as sd
|
|
13
|
+
from PySide6.QtCore import (
|
|
14
|
+
QEasingCurve,
|
|
15
|
+
QParallelAnimationGroup,
|
|
16
|
+
QPoint,
|
|
17
|
+
QPropertyAnimation,
|
|
18
|
+
QRect,
|
|
19
|
+
QRectF,
|
|
20
|
+
QSettings,
|
|
21
|
+
Qt,
|
|
22
|
+
QTimer,
|
|
23
|
+
Signal,
|
|
24
|
+
)
|
|
25
|
+
from PySide6.QtGui import QColor, QFont, QGuiApplication, QHideEvent, QMouseEvent, QPainter, QPainterPath, QPaintEvent, QPen, QPixmap
|
|
26
|
+
from PySide6.QtWidgets import QWidget
|
|
27
|
+
|
|
28
|
+
from claudetan.config import ROOT
|
|
29
|
+
|
|
30
|
+
# Constants
|
|
31
|
+
ASSETS: Path = ROOT / "assets" / "claude"
|
|
32
|
+
SPRITE_HEIGHT: int = 400
|
|
33
|
+
""" Display height of the sprites, which all share one canvas with her eyes on the same pixels. """
|
|
34
|
+
BUBBLE_WIDTH: int = 250
|
|
35
|
+
BUBBLE_CHARS: int = 220
|
|
36
|
+
""" Longest text the bubble shows, the full reply staying readable in the session. """
|
|
37
|
+
SPRITE_TOP: int = 24
|
|
38
|
+
FACE_Y: float = 0.49
|
|
39
|
+
""" Vertical position of the face center in the sprites, as a fraction of the sprite height. """
|
|
40
|
+
FACE: QRect = QRect(300, 160, 240, 240)
|
|
41
|
+
""" Square around her face on the sprite canvas, cropped for the pill and the tray icon. """
|
|
42
|
+
PILL_SIZE: int = 64
|
|
43
|
+
ACCENT: QColor = QColor(217, 119, 87)
|
|
44
|
+
LONG_PRESS_MS: int = 200
|
|
45
|
+
CLICK_SLOP: int = 6
|
|
46
|
+
""" Pixels the cursor may travel during a press that still counts as a click. """
|
|
47
|
+
TALK_FRAMES: dict[str, tuple[str, ...]] = {
|
|
48
|
+
"idle": ("idle", "idle_talk_open", "idle_talk_o", "idle_talk_wide"),
|
|
49
|
+
"hello": ("hello", "hello_talk_open", "hello_talk_o", "hello_talk_wide"),
|
|
50
|
+
}
|
|
51
|
+
""" Mouth frames of the poses that can talk. """
|
|
52
|
+
CHIME_RATE: int = 44100
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Classes
|
|
56
|
+
class Avatar(QWidget):
|
|
57
|
+
clicked = Signal()
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
super().__init__(None, Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool)
|
|
61
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
62
|
+
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
|
63
|
+
ratio = QGuiApplication.primaryScreen().devicePixelRatio()
|
|
64
|
+
self.sprites: dict[str, QPixmap] = {}
|
|
65
|
+
for path in ASSETS.glob("*.png"):
|
|
66
|
+
pixmap = QPixmap(str(path)).scaledToHeight(round(SPRITE_HEIGHT * ratio), Qt.TransformationMode.SmoothTransformation)
|
|
67
|
+
pixmap.setDevicePixelRatio(ratio)
|
|
68
|
+
self.sprites[path.stem] = pixmap
|
|
69
|
+
self.sprite_width = round(self.sprites["idle"].width() / ratio)
|
|
70
|
+
self.sprite_left = BUBBLE_WIDTH - round(0.3 * self.sprite_width)
|
|
71
|
+
self.resize(self.sprite_left + self.sprite_width, SPRITE_TOP + SPRITE_HEIGHT)
|
|
72
|
+
|
|
73
|
+
self.expression = "idle"
|
|
74
|
+
self.frame = "idle"
|
|
75
|
+
self.bubble = ""
|
|
76
|
+
self.press_offset: QPoint | None = None
|
|
77
|
+
self.press_origin = QPoint()
|
|
78
|
+
self.dragging = False
|
|
79
|
+
self.settings = QSettings("ClaudeTan", "overlay")
|
|
80
|
+
|
|
81
|
+
self.motion = QPropertyAnimation(self, b"pos")
|
|
82
|
+
self.fade = QPropertyAnimation(self, b"windowOpacity")
|
|
83
|
+
self.slide = QParallelAnimationGroup(self)
|
|
84
|
+
for animation in (self.motion, self.fade):
|
|
85
|
+
animation.setDuration(260)
|
|
86
|
+
animation.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
87
|
+
self.slide.addAnimation(animation)
|
|
88
|
+
self.slide.finished.connect(self.hide_if_faded)
|
|
89
|
+
|
|
90
|
+
self.long_press = QTimer(self)
|
|
91
|
+
self.long_press.setSingleShot(True)
|
|
92
|
+
self.long_press.setInterval(LONG_PRESS_MS)
|
|
93
|
+
self.long_press.timeout.connect(self.start_drag)
|
|
94
|
+
self.blink = QTimer(self)
|
|
95
|
+
self.blink.setSingleShot(True)
|
|
96
|
+
self.blink.timeout.connect(self.close_eyes)
|
|
97
|
+
self.mouth = QTimer(self)
|
|
98
|
+
self.mouth.setInterval(110)
|
|
99
|
+
self.mouth.timeout.connect(self.flap)
|
|
100
|
+
self.schedule_blink()
|
|
101
|
+
|
|
102
|
+
def set_expression(self, expression: str, *, talking: bool = False, bubble: str = "") -> None:
|
|
103
|
+
self.expression = expression
|
|
104
|
+
self.frame = expression
|
|
105
|
+
self.bubble = shorten(bubble)
|
|
106
|
+
if talking and expression in TALK_FRAMES:
|
|
107
|
+
self.mouth.start()
|
|
108
|
+
else:
|
|
109
|
+
self.mouth.stop()
|
|
110
|
+
self.update()
|
|
111
|
+
|
|
112
|
+
def pop_in(self) -> None:
|
|
113
|
+
if not self.isVisible():
|
|
114
|
+
self.setWindowOpacity(0)
|
|
115
|
+
self.move(self.home() + QPoint(0, 40))
|
|
116
|
+
self.show()
|
|
117
|
+
self.slide_to(self.home(), 1.0)
|
|
118
|
+
|
|
119
|
+
def pop_out(self) -> None:
|
|
120
|
+
if self.isVisible():
|
|
121
|
+
self.slide_to(self.home() + QPoint(0, 40), 0.0)
|
|
122
|
+
|
|
123
|
+
def home(self) -> QPoint:
|
|
124
|
+
""" Where she stands: where she was last dropped while that screen is plugged in, else the main screen's bottom-right corner.
|
|
125
|
+
|
|
126
|
+
The fallback is never saved, so she goes back to the other screen as soon as it returns.
|
|
127
|
+
"""
|
|
128
|
+
saved = self.settings.value("pos")
|
|
129
|
+
if isinstance(saved, QPoint) and QGuiApplication.screenAt(saved + self.rect().center()):
|
|
130
|
+
return saved
|
|
131
|
+
area = QGuiApplication.primaryScreen().availableGeometry()
|
|
132
|
+
return QPoint(area.right() - self.width(), area.bottom() - self.height())
|
|
133
|
+
|
|
134
|
+
def slide_to(self, position: QPoint, opacity: float) -> None:
|
|
135
|
+
self.slide.stop()
|
|
136
|
+
self.motion.setEndValue(position)
|
|
137
|
+
self.fade.setEndValue(opacity)
|
|
138
|
+
self.slide.start()
|
|
139
|
+
|
|
140
|
+
def hide_if_faded(self) -> None:
|
|
141
|
+
if self.fade.endValue() == 0.0:
|
|
142
|
+
self.hide()
|
|
143
|
+
|
|
144
|
+
def schedule_blink(self) -> None:
|
|
145
|
+
self.blink.start(random.randint(2500, 6000))
|
|
146
|
+
|
|
147
|
+
def close_eyes(self) -> None:
|
|
148
|
+
if self.frame == "idle" and not self.mouth.isActive():
|
|
149
|
+
self.frame = "idle_blink"
|
|
150
|
+
self.update()
|
|
151
|
+
QTimer.singleShot(130, self.open_eyes)
|
|
152
|
+
self.schedule_blink()
|
|
153
|
+
|
|
154
|
+
def open_eyes(self) -> None:
|
|
155
|
+
if self.frame == "idle_blink":
|
|
156
|
+
self.frame = "idle"
|
|
157
|
+
self.update()
|
|
158
|
+
|
|
159
|
+
def flap(self) -> None:
|
|
160
|
+
self.frame = random.choice([f for f in TALK_FRAMES[self.expression] if f != self.frame])
|
|
161
|
+
self.update()
|
|
162
|
+
|
|
163
|
+
@override
|
|
164
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
165
|
+
painter = QPainter(self)
|
|
166
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
167
|
+
painter.drawPixmap(QPoint(self.sprite_left, SPRITE_TOP), self.sprites[self.frame])
|
|
168
|
+
if self.bubble:
|
|
169
|
+
self.paint_bubble(painter)
|
|
170
|
+
|
|
171
|
+
def paint_bubble(self, painter: QPainter) -> None:
|
|
172
|
+
painter.setFont(QFont("Segoe UI", 11))
|
|
173
|
+
text = painter.boundingRect(QRectF(0, 0, BUBBLE_WIDTH - 48, 400), Qt.TextFlag.TextWordWrap, self.bubble)
|
|
174
|
+
# Growing upward from just above her hands keeps the finger of `point` and the wave of `hello` in sight
|
|
175
|
+
bottom = SPRITE_TOP + FACE_Y * SPRITE_HEIGHT - 26
|
|
176
|
+
box = QRectF(8, max(4.0, bottom - text.height() - 24), BUBBLE_WIDTH - 20, text.height() + 24)
|
|
177
|
+
path = QPainterPath()
|
|
178
|
+
path.addRoundedRect(box, 14, 14)
|
|
179
|
+
tail = QPainterPath()
|
|
180
|
+
tail.moveTo(box.right() - 4, box.bottom() - 34)
|
|
181
|
+
tail.lineTo(box.right() + 18, box.bottom() - 22)
|
|
182
|
+
tail.lineTo(box.right() - 4, box.bottom() - 18)
|
|
183
|
+
painter.setPen(ACCENT)
|
|
184
|
+
painter.setBrush(QColor(255, 250, 244, 240))
|
|
185
|
+
painter.drawPath(path.united(tail))
|
|
186
|
+
painter.setPen(QColor(40, 30, 25))
|
|
187
|
+
painter.drawText(box.adjusted(14, 12, -14, -12), Qt.TextFlag.TextWordWrap, self.bubble)
|
|
188
|
+
|
|
189
|
+
@override
|
|
190
|
+
def mousePressEvent(self, event: QMouseEvent) -> None:
|
|
191
|
+
self.press_origin = event.globalPosition().toPoint()
|
|
192
|
+
self.press_offset = self.press_origin - self.pos()
|
|
193
|
+
self.long_press.start()
|
|
194
|
+
|
|
195
|
+
@override
|
|
196
|
+
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
|
197
|
+
cursor = event.globalPosition().toPoint()
|
|
198
|
+
if self.dragging and self.press_offset is not None:
|
|
199
|
+
self.move(cursor - self.press_offset)
|
|
200
|
+
elif self.long_press.isActive() and (cursor - self.press_origin).manhattanLength() > CLICK_SLOP:
|
|
201
|
+
self.long_press.stop()
|
|
202
|
+
|
|
203
|
+
@override
|
|
204
|
+
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
|
205
|
+
if self.long_press.isActive():
|
|
206
|
+
self.long_press.stop()
|
|
207
|
+
self.clicked.emit()
|
|
208
|
+
elif self.dragging:
|
|
209
|
+
self.dragging = False
|
|
210
|
+
self.unsetCursor()
|
|
211
|
+
self.settings.setValue("pos", self.pos())
|
|
212
|
+
self.press_offset = None
|
|
213
|
+
|
|
214
|
+
def start_drag(self) -> None:
|
|
215
|
+
self.dragging = True
|
|
216
|
+
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Pill(QWidget):
|
|
220
|
+
""" Her face in a disc ringed by a spinning arc, shown in her place while a long task runs. """
|
|
221
|
+
clicked = Signal()
|
|
222
|
+
|
|
223
|
+
def __init__(self) -> None:
|
|
224
|
+
super().__init__(None, Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool)
|
|
225
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
226
|
+
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
|
227
|
+
self.resize(PILL_SIZE, PILL_SIZE)
|
|
228
|
+
self.face = face(PILL_SIZE)
|
|
229
|
+
self.angle = 0
|
|
230
|
+
self.spin = QTimer(self)
|
|
231
|
+
self.spin.setInterval(30)
|
|
232
|
+
self.spin.timeout.connect(self.turn)
|
|
233
|
+
|
|
234
|
+
def show_at(self, corner: QPoint) -> None:
|
|
235
|
+
""" Shows the pill with its bottom-right corner on `corner`. """
|
|
236
|
+
self.move(corner - QPoint(PILL_SIZE, PILL_SIZE))
|
|
237
|
+
self.show()
|
|
238
|
+
self.spin.start()
|
|
239
|
+
|
|
240
|
+
def turn(self) -> None:
|
|
241
|
+
self.angle = (self.angle - 8) % 360
|
|
242
|
+
self.update()
|
|
243
|
+
|
|
244
|
+
@override
|
|
245
|
+
def hideEvent(self, event: QHideEvent) -> None:
|
|
246
|
+
self.spin.stop()
|
|
247
|
+
|
|
248
|
+
@override
|
|
249
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
250
|
+
painter = QPainter(self)
|
|
251
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
252
|
+
ring = QRectF(4, 4, PILL_SIZE - 8, PILL_SIZE - 8)
|
|
253
|
+
disc = QPainterPath()
|
|
254
|
+
disc.addEllipse(ring)
|
|
255
|
+
painter.setClipPath(disc)
|
|
256
|
+
painter.drawPixmap(ring.toRect(), self.face)
|
|
257
|
+
painter.setClipping(False)
|
|
258
|
+
painter.setPen(QPen(QColor(255, 250, 244), 4))
|
|
259
|
+
painter.drawEllipse(ring)
|
|
260
|
+
painter.setPen(QPen(ACCENT, 4, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
|
261
|
+
painter.drawArc(ring, self.angle * 16, 110 * 16)
|
|
262
|
+
|
|
263
|
+
@override
|
|
264
|
+
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
|
265
|
+
self.clicked.emit()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Functions
|
|
269
|
+
def face(size: int) -> QPixmap:
|
|
270
|
+
""" Her face cropped from `idle`, `size` device-independent pixels wide. """
|
|
271
|
+
ratio = QGuiApplication.primaryScreen().devicePixelRatio()
|
|
272
|
+
pixels = round(size * ratio)
|
|
273
|
+
cropped = QPixmap(str(ASSETS / "idle.png")).copy(FACE)
|
|
274
|
+
pixmap = cropped.scaled(pixels, pixels, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
275
|
+
pixmap.setDevicePixelRatio(ratio)
|
|
276
|
+
return pixmap
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def shorten(text: str) -> str:
|
|
280
|
+
""" The text as the bubble shows it: Markdown marks dropped, whitespace collapsed, cut at a word past `BUBBLE_CHARS`.
|
|
281
|
+
|
|
282
|
+
>>> shorten("**Done**: edited `app.py`.\\n\\n- one\\n- two")
|
|
283
|
+
'Done: edited app.py. one two'
|
|
284
|
+
>>> shorten("word " * 100)[-12:]
|
|
285
|
+
'word word...'
|
|
286
|
+
"""
|
|
287
|
+
plain = " ".join(re.sub(r"[*`#]|^\s*[-+] ", "", text, flags=re.MULTILINE).split())
|
|
288
|
+
return plain if len(plain) <= BUBBLE_CHARS else plain[:BUBBLE_CHARS].rsplit(" ", 1)[0] + "..."
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def chime(*notes: float) -> None:
|
|
292
|
+
""" A short bell arpeggio of the given frequencies in Hz, played without blocking. """
|
|
293
|
+
t = np.arange(int(0.4 * CHIME_RATE)) / CHIME_RATE
|
|
294
|
+
step = int(0.08 * CHIME_RATE)
|
|
295
|
+
wave = np.zeros(step * (len(notes) - 1) + len(t), np.float32)
|
|
296
|
+
for i, note in enumerate(notes):
|
|
297
|
+
wave[i * step:i * step + len(t)] += np.sin(2 * np.pi * note * t) * np.exp(-8 * t)
|
|
298
|
+
sd.play(0.15 * wave, CHIME_RATE)
|
|
299
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
""" User settings: the defaults shipped next to this module, overridden by the keys set in the user's copy. """
|
|
2
|
+
# Imports
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
# Constants
|
|
12
|
+
ROOT: Path = Path(__file__).resolve().parent.parent
|
|
13
|
+
""" The ClaudeTan repository, which the app runs from. """
|
|
14
|
+
APP_DATA: Path = Path(os.environ["APPDATA"]) / "ClaudeTan"
|
|
15
|
+
DEFAULTS: Path = Path(__file__).with_name("config.toml")
|
|
16
|
+
USER_CONFIG: Path = APP_DATA / "config.toml"
|
|
17
|
+
LOG_FILE: Path = APP_DATA / "claudetan.log"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Classes
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Config:
|
|
23
|
+
language: str | None
|
|
24
|
+
""" Whisper language code, None to detect it on each sentence. """
|
|
25
|
+
wake: re.Pattern[str]
|
|
26
|
+
prompt: str
|
|
27
|
+
""" The user's additions to the system prompt of the hidden session. """
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def load(cls) -> Self:
|
|
31
|
+
""" The settings, after copying the defaults to `USER_CONFIG` on the first run so the user has a file to edit. """
|
|
32
|
+
if not USER_CONFIG.exists():
|
|
33
|
+
APP_DATA.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
shutil.copyfile(DEFAULTS, USER_CONFIG)
|
|
35
|
+
values = tomllib.loads(DEFAULTS.read_text(encoding="utf-8")) | tomllib.loads(USER_CONFIG.read_text(encoding="utf-8"))
|
|
36
|
+
return cls(
|
|
37
|
+
language=None if values["language"] == "auto" else values["language"],
|
|
38
|
+
wake=re.compile(values["wake"], re.IGNORECASE),
|
|
39
|
+
prompt=values["prompt"],
|
|
40
|
+
)
|
|
41
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ClaudeTan settings. Quit the app from its tray icon and start it again to apply a change.
|
|
2
|
+
|
|
3
|
+
# Language of the speech recognition: "fr", "en", or "auto" to detect it on each sentence
|
|
4
|
+
language = "auto"
|
|
5
|
+
|
|
6
|
+
# Wake phrase, matched ignoring case at the start of each sentence as Whisper spells it ("Hé Claude" often comes out as "Et Claude").
|
|
7
|
+
# Its first group is the command said in the same breath.
|
|
8
|
+
wake = '^\W*(?:hey|hé|he|eh|et|dis|ok|okay)\W+(?:claude|clode|clod|clodo|clown)\b[\s,.!?]*(.*)'
|
|
9
|
+
|
|
10
|
+
# Your additions to the system prompt of the hidden Claude session, after what the app writes there itself:
|
|
11
|
+
# where ClaudeTan is installed and how it started the session. Write them between the two ''' lines, for example:
|
|
12
|
+
# Mes projets sont dans D:\advanced_desktop, mes captures d'écran dans D:\Captures.
|
|
13
|
+
prompt = '''
|
|
14
|
+
'''
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
""" The terminal the command-line entry points print to. """
|
|
2
|
+
# Imports
|
|
3
|
+
import ctypes
|
|
4
|
+
|
|
5
|
+
# Constants
|
|
6
|
+
STD_OUTPUT_HANDLE: int = -11
|
|
7
|
+
ENABLE_VIRTUAL_TERMINAL_PROCESSING: int = 0x4
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Functions
|
|
11
|
+
def enable_colors() -> None:
|
|
12
|
+
""" Turns on color codes in the console, which Windows' classic console prints as text otherwise. """
|
|
13
|
+
kernel32 = ctypes.windll.kernel32
|
|
14
|
+
console = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
|
|
15
|
+
mode = ctypes.c_uint32()
|
|
16
|
+
if kernel32.GetConsoleMode(console, ctypes.byref(mode)):
|
|
17
|
+
kernel32.SetConsoleMode(console, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
|
|
18
|
+
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
""" The hidden Claude Code session: `claude --remote-control` in a pseudo terminal, and the hook events it posts to the app. """
|
|
2
|
+
# Imports
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
import webbrowser
|
|
11
|
+
from functools import partial
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, override
|
|
15
|
+
|
|
16
|
+
import pyte
|
|
17
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
18
|
+
from winpty import PtyProcess
|
|
19
|
+
|
|
20
|
+
from claudetan.config import APP_DATA, ROOT, USER_CONFIG
|
|
21
|
+
|
|
22
|
+
# Constants
|
|
23
|
+
type JsonDict = dict[str, Any]
|
|
24
|
+
SYSTEM_PROMPT: str = (
|
|
25
|
+
f"You are Claude-tan, the voice assistant of ClaudeTan, an app installed in {ROOT} that keeps its settings in {USER_CONFIG}.\n"
|
|
26
|
+
"ClaudeTan started this Claude Code session hidden in a pseudo-terminal, with Remote Control and the auto permission mode, "
|
|
27
|
+
"resumes it each time the app starts, and follows it through hooks to animate your avatar on the screen.\n"
|
|
28
|
+
"Prompts come from the user's voice, transcribed by Whisper, which can mishear words, "
|
|
29
|
+
"or from the Remote Control on claude.ai or in the Claude app on their iPhone.\n"
|
|
30
|
+
"Your replies appear in a speech bubble next to your avatar, which only shows the start of long ones.\n"
|
|
31
|
+
)
|
|
32
|
+
""" What the app tells the session about itself, before the user's additions from the config. """
|
|
33
|
+
HOOK_EVENTS: tuple[str, ...] = (
|
|
34
|
+
"UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Notification", "MessageDisplay",
|
|
35
|
+
"SubagentStart", "SubagentStop", "Stop", "StopFailure",
|
|
36
|
+
)
|
|
37
|
+
SESSION_ID: Path = APP_DATA / "session_id"
|
|
38
|
+
""" The conversation the app resumes, saved once a hook reports it, so a session that never got a prompt is not resumed. """
|
|
39
|
+
SESSION_URL: re.Pattern[str] = re.compile(r"https://claude\.ai/code/session_\w+")
|
|
40
|
+
PLAN_USAGE: re.Pattern[str] = re.compile(
|
|
41
|
+
r"Current session\W+(?P<window>\d+)% used\s+Resets (?P<window_reset>[^)]*\)).*?"
|
|
42
|
+
r"Current week \(all models\)\W+(?P<week>\d+)% used\s+Resets (?P<week_reset>[^)]*\))",
|
|
43
|
+
re.DOTALL,
|
|
44
|
+
)
|
|
45
|
+
""" The two limits of the `/usage` panel, each followed by when it resets, such as "Resets Oct 12, 12am (Europe/Paris)". """
|
|
46
|
+
NESTED_SESSION_VARS: tuple[str, ...] = (
|
|
47
|
+
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION",
|
|
48
|
+
"CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_PID",
|
|
49
|
+
)
|
|
50
|
+
""" Set when the app runs inside another Claude Code session; the hosted `claude` refuses to start nested while they are present. """
|
|
51
|
+
PASTE_DELAY_MS: int = 400
|
|
52
|
+
""" Pause between the text and Enter, so the TUI takes the text as one paste before submitting it. """
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Classes
|
|
56
|
+
class HookServer(QObject):
|
|
57
|
+
""" Local endpoint for the session's `type: "http"` hooks, relaying each payload to the Qt thread. """
|
|
58
|
+
posted = Signal(dict)
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
relay = self.posted
|
|
63
|
+
|
|
64
|
+
class Handler(BaseHTTPRequestHandler):
|
|
65
|
+
def do_POST(self) -> None:
|
|
66
|
+
relay.emit(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
|
|
67
|
+
self.send_response(200)
|
|
68
|
+
self.end_headers()
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
75
|
+
self.url = f"http://127.0.0.1:{server.server_port}"
|
|
76
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Session:
|
|
80
|
+
""" Claude Code started in the repo, resuming the saved conversation, with its hooks posting to `hooks_url`. """
|
|
81
|
+
|
|
82
|
+
def __init__(self, hooks_url: str, additions: str) -> None:
|
|
83
|
+
settings = APP_DATA / "session_settings.json"
|
|
84
|
+
hooks = {name: [{"hooks": [{"type": "http", "url": hooks_url, "timeout": 5}]}] for name in HOOK_EVENTS}
|
|
85
|
+
settings.write_text(json.dumps({"hooks": hooks}), encoding="utf-8")
|
|
86
|
+
prompt = APP_DATA / "session_prompt.md"
|
|
87
|
+
prompt.write_text(system_prompt(additions), encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
self.id = SESSION_ID.read_text() if SESSION_ID.exists() else ""
|
|
90
|
+
resume = ["--resume", self.id] if self.id else ["--session-id", str(uuid.uuid4())]
|
|
91
|
+
claude = [
|
|
92
|
+
"claude", "--remote-control", "ClaudeTan", "--permission-mode", "auto",
|
|
93
|
+
"--settings", str(settings), "--append-system-prompt-file", str(prompt), *resume,
|
|
94
|
+
]
|
|
95
|
+
# chcp 65001: without it the pseudo console feeds Claude Code accented letters in cp850
|
|
96
|
+
argv = ["cmd.exe", "/c", "chcp", "65001", ">nul", "&&", *claude]
|
|
97
|
+
env = {name: value for name, value in os.environ.items() if name not in NESTED_SESSION_VARS}
|
|
98
|
+
self.pty = PtyProcess.spawn(argv, cwd=str(ROOT), env=env, dimensions=(50, 200))
|
|
99
|
+
self.screen = pyte.Screen(200, 50)
|
|
100
|
+
""" What the terminal shows, rebuilt from its output: Claude Code only redraws the characters that change. """
|
|
101
|
+
self.screen_lock = threading.Lock()
|
|
102
|
+
self.url = "https://claude.ai/code"
|
|
103
|
+
self.started = threading.Event()
|
|
104
|
+
""" Set once Remote Control prints the session's URL, when the session is ready for prompts. """
|
|
105
|
+
threading.Thread(target=self.read_output, daemon=True).start()
|
|
106
|
+
|
|
107
|
+
def read_output(self) -> None:
|
|
108
|
+
""" Renders the terminal's output, read continuously so the session never blocks on a full pipe. """
|
|
109
|
+
stream = pyte.Stream(self.screen)
|
|
110
|
+
while self.pty.isalive():
|
|
111
|
+
try:
|
|
112
|
+
output = self.pty.read(4096)
|
|
113
|
+
except EOFError:
|
|
114
|
+
return
|
|
115
|
+
with self.screen_lock:
|
|
116
|
+
stream.feed(output)
|
|
117
|
+
if not self.started.is_set() and (match := SESSION_URL.search(self.text())):
|
|
118
|
+
self.url = match.group()
|
|
119
|
+
self.started.set()
|
|
120
|
+
|
|
121
|
+
def plan_usage(self) -> str:
|
|
122
|
+
""" The plan's limits as `/usage` shows them, read before any prompt since keys typed while its panel is open land in it. """
|
|
123
|
+
self.pty.write("/usage")
|
|
124
|
+
time.sleep(PASTE_DELAY_MS / 1000)
|
|
125
|
+
self.pty.write("\r")
|
|
126
|
+
deadline = time.monotonic() + 15
|
|
127
|
+
while not (match := PLAN_USAGE.search(self.text())) and time.monotonic() < deadline:
|
|
128
|
+
time.sleep(0.2)
|
|
129
|
+
self.pty.write("\x1b")
|
|
130
|
+
if not match:
|
|
131
|
+
return "unknown, /usage did not show it"
|
|
132
|
+
return (
|
|
133
|
+
f"{match['window']}% of the 5-hour window, resets {match['window_reset']}; "
|
|
134
|
+
f"{match['week']}% of the week, resets {match['week_reset']}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def text(self) -> str:
|
|
138
|
+
with self.screen_lock:
|
|
139
|
+
return "\n".join(self.screen.display)
|
|
140
|
+
|
|
141
|
+
def on_hook(self, event: JsonDict) -> None:
|
|
142
|
+
if event["session_id"] != self.id:
|
|
143
|
+
self.id = event["session_id"]
|
|
144
|
+
SESSION_ID.write_text(self.id)
|
|
145
|
+
|
|
146
|
+
def send(self, prompt: str) -> None:
|
|
147
|
+
self.pty.write(prompt)
|
|
148
|
+
QTimer.singleShot(PASTE_DELAY_MS, partial(self.pty.write, "\r"))
|
|
149
|
+
|
|
150
|
+
def open(self) -> None:
|
|
151
|
+
webbrowser.open(self.url)
|
|
152
|
+
|
|
153
|
+
def close(self) -> None:
|
|
154
|
+
""" Kills Claude Code outright: on a clean exit, Remote Control archives the session, which drops it from claude.ai's list. """
|
|
155
|
+
kill = ["taskkill", "/F", "/T", "/PID", str(self.pty.pid)]
|
|
156
|
+
subprocess.run(kill, capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Functions
|
|
160
|
+
def system_prompt(additions: str) -> str:
|
|
161
|
+
""" Text for `--append-system-prompt-file`: `SYSTEM_PROMPT`, then the user's additions when there are any.
|
|
162
|
+
|
|
163
|
+
>>> system_prompt("\\n") == SYSTEM_PROMPT
|
|
164
|
+
True
|
|
165
|
+
>>> system_prompt("Mes jeux sont dans E:/Games.\\n").removeprefix(SYSTEM_PROMPT)
|
|
166
|
+
'\\nMes jeux sont dans E:/Games.'
|
|
167
|
+
"""
|
|
168
|
+
return f"{SYSTEM_PROMPT}\n{additions.strip()}" if additions.strip() else SYSTEM_PROMPT
|
|
169
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
""" One-time setup, run with `uv run claudetan-setup`: prepares Whisper, then signs in to Claude Code and has it trust the repo. """
|
|
2
|
+
# Imports
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import stouputils as stp
|
|
10
|
+
|
|
11
|
+
from claudetan.config import ROOT
|
|
12
|
+
from claudetan.console import enable_colors
|
|
13
|
+
from claudetan.voice import RATE, load_whisper
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Functions
|
|
17
|
+
def main() -> None:
|
|
18
|
+
enable_colors()
|
|
19
|
+
stp.progress("Downloading Whisper (1.6 GB the first time) and running it once on the GPU...")
|
|
20
|
+
segments, _ = load_whisper().transcribe(np.zeros(RATE, np.float32), language="en")
|
|
21
|
+
list(segments)
|
|
22
|
+
stp.info("Whisper runs on the GPU")
|
|
23
|
+
|
|
24
|
+
if not shutil.which("claude"):
|
|
25
|
+
stp.warning(f"Install Claude Code, then run this setup again: {stp.CYAN}https://code.claude.com/docs/en/setup")
|
|
26
|
+
return
|
|
27
|
+
if not signed_in():
|
|
28
|
+
stp.progress("Claude Code opens its sign-in page: sign in with your Claude Pro or Max account.")
|
|
29
|
+
subprocess.run(["claude", "auth", "login"])
|
|
30
|
+
# The decision stays with the user: Claude Code shows its own dialog, and records the answer in a file it keeps rewriting
|
|
31
|
+
if not trusted():
|
|
32
|
+
stp.progress('Claude Code opens and asks whether you trust this folder: pick "Yes, I trust this folder", then type /exit')
|
|
33
|
+
subprocess.run(["claude"], cwd=ROOT)
|
|
34
|
+
|
|
35
|
+
if signed_in() and trusted():
|
|
36
|
+
stp.info(f"Claude Code is ready. Start the app with: {stp.CYAN}uv run claudetan")
|
|
37
|
+
else:
|
|
38
|
+
stp.warning("Claude Code is not signed in or does not trust this folder yet: run this setup again.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def signed_in() -> bool:
|
|
42
|
+
""" Whether Claude Code is signed in with a Claude subscription, which the app needs instead of an API key. """
|
|
43
|
+
status = json.loads(subprocess.run(["claude", "auth", "status"], capture_output=True, text=True).stdout)
|
|
44
|
+
return status.get("authMethod") == "claude.ai"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def trusted() -> bool:
|
|
48
|
+
""" Whether the user has told Claude Code it may work in this repository. """
|
|
49
|
+
projects = json.loads((Path.home() / ".claude.json").read_text(encoding="utf-8")).get("projects", {})
|
|
50
|
+
return bool(projects.get(ROOT.as_posix(), {}).get("hasTrustDialogAccepted"))
|
|
51
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
""" The tray icon: its menu, and starting the app with Windows. """
|
|
2
|
+
# Imports
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import winreg
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import Signal
|
|
9
|
+
from PySide6.QtGui import QCursor, QIcon
|
|
10
|
+
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
|
|
11
|
+
|
|
12
|
+
from claudetan.avatar import face
|
|
13
|
+
from claudetan.config import APP_DATA, LOG_FILE, USER_CONFIG
|
|
14
|
+
|
|
15
|
+
# Constants
|
|
16
|
+
RUN_KEY: str = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
|
17
|
+
""" Registry key, under HKEY_CURRENT_USER, of the programs Windows starts at logon. """
|
|
18
|
+
LAUNCHER: Path = Path(sys.prefix) / "Scripts" / "claudetan.exe"
|
|
19
|
+
""" Entry point that `uv sync` installs in the project environment, which starts the app in the background. """
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Classes
|
|
23
|
+
class Tray(QSystemTrayIcon):
|
|
24
|
+
open_session = Signal()
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
super().__init__(QIcon(face(64)))
|
|
28
|
+
self.setToolTip("Claude-tan")
|
|
29
|
+
self.menu = QMenu()
|
|
30
|
+
self.menu.addAction("Ouvrir la session", self.open_session.emit)
|
|
31
|
+
self.menu.addAction("Ouvrir la config", lambda: os.startfile(USER_CONFIG))
|
|
32
|
+
self.menu.addAction("Voir le journal", lambda: os.startfile(LOG_FILE))
|
|
33
|
+
self.menu.addAction("Ouvrir le dossier", lambda: os.startfile(APP_DATA))
|
|
34
|
+
startup = self.menu.addAction("Démarrer avec Windows")
|
|
35
|
+
startup.setCheckable(True)
|
|
36
|
+
startup.setChecked(starts_with_windows())
|
|
37
|
+
startup.toggled.connect(start_with_windows)
|
|
38
|
+
self.menu.addSeparator()
|
|
39
|
+
self.menu.addAction("Quitter", QApplication.quit)
|
|
40
|
+
self.setContextMenu(self.menu)
|
|
41
|
+
self.activated.connect(self.on_activated)
|
|
42
|
+
|
|
43
|
+
def on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
|
|
44
|
+
""" Opens the menu on a left click too, Windows only showing it on a right click. """
|
|
45
|
+
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
|
46
|
+
self.menu.popup(QCursor.pos())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Functions
|
|
50
|
+
def starts_with_windows() -> bool:
|
|
51
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
|
|
52
|
+
try:
|
|
53
|
+
winreg.QueryValueEx(key, "ClaudeTan")
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def start_with_windows(enabled: bool) -> None:
|
|
60
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, access=winreg.KEY_SET_VALUE) as key:
|
|
61
|
+
if enabled:
|
|
62
|
+
winreg.SetValueEx(key, "ClaudeTan", 0, winreg.REG_SZ, f'"{LAUNCHER}"')
|
|
63
|
+
else:
|
|
64
|
+
winreg.DeleteValue(key, "ClaudeTan")
|
|
65
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
""" The microphone, cut into sentences by Silero VAD and transcribed by Whisper, reduced to the commands spoken to her. """
|
|
2
|
+
# Imports
|
|
3
|
+
import queue
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import sounddevice as sd
|
|
12
|
+
from faster_whisper import WhisperModel
|
|
13
|
+
from faster_whisper.vad import get_vad_model
|
|
14
|
+
from PySide6.QtCore import QObject, Signal
|
|
15
|
+
|
|
16
|
+
# Constants
|
|
17
|
+
type Audio = np.ndarray[tuple[int], np.dtype[np.float32]]
|
|
18
|
+
RATE: int = 16000
|
|
19
|
+
FRAME: int = 512
|
|
20
|
+
""" Samples per VAD step, the window Silero expects at 16 kHz (32 ms). """
|
|
21
|
+
EARLY_CHECK_FRAMES: int = 40
|
|
22
|
+
""" Length (1.3 s) at which a sentence still being spoken is transcribed once, so she reacts to the wake phrase before it ends. """
|
|
23
|
+
FOLLOW_UP_S: float = 8.0
|
|
24
|
+
""" How long a lone wake phrase keeps her listening for the command. """
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Classes
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Speech:
|
|
30
|
+
audio: Audio
|
|
31
|
+
ended: bool
|
|
32
|
+
""" False for the opening of a sentence still being spoken. """
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Listener(QObject):
|
|
36
|
+
""" Runs the microphone pipeline on its own thread and reports to the Qt thread through its signals. """
|
|
37
|
+
woke = Signal()
|
|
38
|
+
heard = Signal(str)
|
|
39
|
+
""" A command, said after the wake phrase or alone within `FOLLOW_UP_S` of it. """
|
|
40
|
+
|
|
41
|
+
def __init__(self, language: str | None, wake: re.Pattern[str]) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.language = language
|
|
44
|
+
self.wake = wake
|
|
45
|
+
self.loaded = threading.Event()
|
|
46
|
+
|
|
47
|
+
def run(self, frames: Iterator[Audio]) -> None:
|
|
48
|
+
whisper = load_whisper()
|
|
49
|
+
self.loaded.set()
|
|
50
|
+
awake_until = 0.0
|
|
51
|
+
for speech in utterances(frames):
|
|
52
|
+
segments, _ = whisper.transcribe(speech.audio, language=self.language, beam_size=1, condition_on_previous_text=False)
|
|
53
|
+
text = " ".join(segment.text.strip() for segment in segments)
|
|
54
|
+
match = self.wake.match(text)
|
|
55
|
+
if match:
|
|
56
|
+
if time.monotonic() > awake_until:
|
|
57
|
+
self.woke.emit()
|
|
58
|
+
awake_until = time.monotonic() + FOLLOW_UP_S
|
|
59
|
+
|
|
60
|
+
command = match.group(1) if match else text
|
|
61
|
+
if speech.ended and command and time.monotonic() < awake_until:
|
|
62
|
+
self.heard.emit(command)
|
|
63
|
+
awake_until = 0.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Functions
|
|
67
|
+
def load_whisper() -> WhisperModel:
|
|
68
|
+
""" Whisper on the GPU, downloaded from Hugging Face on the first call (1.6 GB). """
|
|
69
|
+
return WhisperModel("large-v3-turbo", device="cuda", compute_type="int8_float16")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def microphone() -> Iterator[Audio]:
|
|
73
|
+
frames: queue.Queue[Audio] = queue.Queue()
|
|
74
|
+
with sd.InputStream(
|
|
75
|
+
samplerate=RATE, channels=1, dtype="float32", blocksize=FRAME,
|
|
76
|
+
callback=lambda data, _count, _time, _status: frames.put(data[:, 0].copy()),
|
|
77
|
+
):
|
|
78
|
+
while True:
|
|
79
|
+
yield frames.get()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def utterances(frames: Iterator[Audio]) -> Iterator[Speech]:
|
|
83
|
+
""" Sentences cut out of the frames: one starts after 250 ms above the threshold and ends after 800 ms of silence or 15 s.
|
|
84
|
+
|
|
85
|
+
Each sentence reaching `EARLY_CHECK_FRAMES` is also yielded once before it ends.
|
|
86
|
+
"""
|
|
87
|
+
vad = get_vad_model()
|
|
88
|
+
window: list[Audio] = []
|
|
89
|
+
speech: list[Audio] = []
|
|
90
|
+
voiced = silent = 0
|
|
91
|
+
for frame in frames:
|
|
92
|
+
window = [*window[-15:], frame]
|
|
93
|
+
probability = float(vad(np.concatenate(window))[-1])
|
|
94
|
+
voiced = voiced + 1 if probability > 0.5 else 0
|
|
95
|
+
silent = silent + 1 if probability < 0.35 else 0
|
|
96
|
+
if speech:
|
|
97
|
+
speech.append(frame)
|
|
98
|
+
elif voiced >= 8:
|
|
99
|
+
speech = list(window)
|
|
100
|
+
else:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
ended = silent >= 25 or len(speech) >= 15 * RATE // FRAME
|
|
104
|
+
if ended or len(speech) == EARLY_CHECK_FRAMES:
|
|
105
|
+
yield Speech(audio=np.concatenate(speech), ended=ended)
|
|
106
|
+
if ended:
|
|
107
|
+
speech = []
|
|
108
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[tool.uv.build-backend]
|
|
6
|
+
module-root = ""
|
|
7
|
+
|
|
8
|
+
[tool.pyright]
|
|
9
|
+
typeCheckingMode = "strict"
|
|
10
|
+
exclude = ["tools/sprites"]
|
|
11
|
+
|
|
12
|
+
[tool.complexipy]
|
|
13
|
+
paths = ["."]
|
|
14
|
+
max-complexity-allowed = 15
|
|
15
|
+
sort = "desc"
|
|
16
|
+
|
|
17
|
+
[tool.ruff]
|
|
18
|
+
exclude = [
|
|
19
|
+
".git",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
".venv",
|
|
22
|
+
".vscode",
|
|
23
|
+
"build",
|
|
24
|
+
"dist",
|
|
25
|
+
"*.pyi",
|
|
26
|
+
]
|
|
27
|
+
line-length = 135
|
|
28
|
+
src = [
|
|
29
|
+
".",
|
|
30
|
+
"tools/sprites",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = [
|
|
35
|
+
"E",
|
|
36
|
+
"W",
|
|
37
|
+
"F",
|
|
38
|
+
"I",
|
|
39
|
+
"N",
|
|
40
|
+
"UP",
|
|
41
|
+
"B",
|
|
42
|
+
"C4",
|
|
43
|
+
"T20",
|
|
44
|
+
"RUF",
|
|
45
|
+
"RET",
|
|
46
|
+
"SIM",
|
|
47
|
+
"PIE",
|
|
48
|
+
"PERF401",
|
|
49
|
+
"PGH003",
|
|
50
|
+
"FBT003",
|
|
51
|
+
"TRY203",
|
|
52
|
+
"S110",
|
|
53
|
+
"PLR5501",
|
|
54
|
+
]
|
|
55
|
+
ignore = [
|
|
56
|
+
"T201",
|
|
57
|
+
"W191",
|
|
58
|
+
"N803",
|
|
59
|
+
"N806",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint.isort]
|
|
63
|
+
combine-as-imports = true
|
|
64
|
+
|
|
65
|
+
[project]
|
|
66
|
+
name = "claudetan"
|
|
67
|
+
version = "1.0.0"
|
|
68
|
+
description = "Voice-driven Claude Code assistant with an anime avatar overlay"
|
|
69
|
+
keywords = [
|
|
70
|
+
"claude",
|
|
71
|
+
"claude-code",
|
|
72
|
+
"voice-assistant",
|
|
73
|
+
"avatar",
|
|
74
|
+
"overlay",
|
|
75
|
+
"whisper",
|
|
76
|
+
"pyside6",
|
|
77
|
+
]
|
|
78
|
+
requires-python = ">=3.14"
|
|
79
|
+
classifiers = [
|
|
80
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
81
|
+
"Programming Language :: Python :: 3",
|
|
82
|
+
"Programming Language :: Python :: 3.14",
|
|
83
|
+
"Operating System :: Microsoft :: Windows",
|
|
84
|
+
"Typing :: Typed",
|
|
85
|
+
]
|
|
86
|
+
dependencies = [
|
|
87
|
+
"faster-whisper>=1.2",
|
|
88
|
+
"numpy",
|
|
89
|
+
"pyside6>=6.9",
|
|
90
|
+
"pyte>=0.8.2",
|
|
91
|
+
"pywinpty>=2.0",
|
|
92
|
+
"sounddevice",
|
|
93
|
+
"soundfile",
|
|
94
|
+
"stouputils>=26.10.1",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
[[project.authors]]
|
|
98
|
+
name = "Stoupy51"
|
|
99
|
+
email = "stoupy51@gmail.com"
|
|
100
|
+
|
|
101
|
+
[project.scripts]
|
|
102
|
+
claudetan = "claudetan.app:main"
|
|
103
|
+
claudetan-setup = "claudetan.setup:main"
|
|
104
|
+
|
|
105
|
+
[project.urls]
|
|
106
|
+
Issues = "https://github.com/Stoupy51/ClaudeTan/issues"
|
|
107
|
+
Repository = "https://github.com/Stoupy51/ClaudeTan"
|
|
108
|
+
|
|
109
|
+
[dependency-groups]
|
|
110
|
+
dev = [
|
|
111
|
+
"pyright",
|
|
112
|
+
"pytest",
|
|
113
|
+
"ruff",
|
|
114
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[tool.uv.build-backend]
|
|
6
|
+
module-root = ""
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "claudetan"
|
|
10
|
+
version = "1.0.0"
|
|
11
|
+
description = "Voice-driven Claude Code assistant with an anime avatar overlay"
|
|
12
|
+
keywords = ["claude", "claude-code", "voice-assistant", "avatar", "overlay", "whisper", "pyside6"]
|
|
13
|
+
requires-python = ">=3.14"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Operating System :: Microsoft :: Windows",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"faster-whisper>=1.2",
|
|
23
|
+
"numpy",
|
|
24
|
+
"pyside6>=6.9",
|
|
25
|
+
"pyte>=0.8.2",
|
|
26
|
+
"pywinpty>=2.0",
|
|
27
|
+
"sounddevice",
|
|
28
|
+
"soundfile",
|
|
29
|
+
"stouputils>=26.10.1",
|
|
30
|
+
]
|
|
31
|
+
authors = [
|
|
32
|
+
{ name = "Stoupy51", email = "stoupy51@gmail.com" },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
claudetan = "claudetan.app:main"
|
|
37
|
+
claudetan-setup = "claudetan.setup:main"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Issues = "https://github.com/Stoupy51/ClaudeTan/issues"
|
|
41
|
+
Repository = "https://github.com/Stoupy51/ClaudeTan"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pyright",
|
|
46
|
+
"pytest",
|
|
47
|
+
"ruff",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.pyright]
|
|
51
|
+
typeCheckingMode = "strict"
|
|
52
|
+
# The sprite pipeline is its own uv project, checked with its own environment
|
|
53
|
+
exclude = ["tools/sprites"]
|
|
54
|
+
|
|
55
|
+
[tool.complexipy]
|
|
56
|
+
paths = ["."]
|
|
57
|
+
max-complexity-allowed = 15
|
|
58
|
+
sort = "desc"
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
exclude = [
|
|
62
|
+
".git",
|
|
63
|
+
".ruff_cache",
|
|
64
|
+
".venv",
|
|
65
|
+
".vscode",
|
|
66
|
+
"build",
|
|
67
|
+
"dist",
|
|
68
|
+
"*.pyi",
|
|
69
|
+
]
|
|
70
|
+
line-length = 135
|
|
71
|
+
# The sprite modules import each other by plain name, as scripts run from their folder
|
|
72
|
+
src = [".", "tools/sprites"]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.isort]
|
|
75
|
+
# Keeps the "name as name" re-exports of a package grouped in a single statement
|
|
76
|
+
combine-as-imports = true
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint]
|
|
79
|
+
select = [
|
|
80
|
+
"E",
|
|
81
|
+
"W",
|
|
82
|
+
"F",
|
|
83
|
+
"I",
|
|
84
|
+
"N",
|
|
85
|
+
"UP",
|
|
86
|
+
"B",
|
|
87
|
+
"C4",
|
|
88
|
+
"T20",
|
|
89
|
+
"RUF",
|
|
90
|
+
"RET",
|
|
91
|
+
"SIM",
|
|
92
|
+
"PIE",
|
|
93
|
+
"PERF401",
|
|
94
|
+
"PGH003",
|
|
95
|
+
"FBT003",
|
|
96
|
+
"TRY203",
|
|
97
|
+
"S110",
|
|
98
|
+
"PLR5501",
|
|
99
|
+
]
|
|
100
|
+
ignore = [
|
|
101
|
+
"T201",
|
|
102
|
+
"W191",
|
|
103
|
+
"N803",
|
|
104
|
+
"N806",
|
|
105
|
+
]
|
|
106
|
+
|