termchat-client 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.
- termchat/__init__.py +10 -0
- termchat/__main__.py +8 -0
- termchat/cli.py +97 -0
- termchat/client.py +251 -0
- termchat/config.py +113 -0
- termchat/viewer.py +93 -0
- termchat_client-0.1.0.dist-info/METADATA +211 -0
- termchat_client-0.1.0.dist-info/RECORD +12 -0
- termchat_client-0.1.0.dist-info/WHEEL +5 -0
- termchat_client-0.1.0.dist-info/entry_points.txt +2 -0
- termchat_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- termchat_client-0.1.0.dist-info/top_level.txt +1 -0
termchat/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
termchat - Lightweight, non-intrusive terminal chat client.
|
|
3
|
+
Connects standard terminal input to your online Render/cloud server and receives text & images.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__author__ = "TermChat Contributors"
|
|
8
|
+
__license__ = "MIT"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
termchat/__main__.py
ADDED
termchat/cli.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI interface for termchat client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from termchat import __version__
|
|
10
|
+
from termchat.client import start
|
|
11
|
+
from termchat.config import (
|
|
12
|
+
get_saved_server_url,
|
|
13
|
+
get_saved_username,
|
|
14
|
+
set_saved_server_url,
|
|
15
|
+
set_saved_username,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="termchat",
|
|
22
|
+
description="Non-intrusive terminal chat client connecting to your online Render/cloud server.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("-v", "--version", action="version", version=f"termchat {__version__}")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-s",
|
|
27
|
+
"--server",
|
|
28
|
+
help="Online server URL (e.g. https://my-chat.onrender.com). Overrides saved config.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-n",
|
|
32
|
+
"--name",
|
|
33
|
+
help="Chat username. Overrides saved config.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--set-server",
|
|
37
|
+
help="Save a default online server URL to ~/.termchat.json and exit.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--set-name",
|
|
41
|
+
help="Save a default username to ~/.termchat.json and exit.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--open",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="Automatically open received images in your OS default image viewer.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--no-thumb",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Disable ANSI image thumbnail previews in the terminal.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
args = parser.parse_args()
|
|
55
|
+
|
|
56
|
+
# Handle configuration saving
|
|
57
|
+
if args.set_server:
|
|
58
|
+
set_saved_server_url(args.set_server)
|
|
59
|
+
print(f"[OK] Default server saved: {args.set_server}")
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
if args.set_name:
|
|
63
|
+
set_saved_username(args.set_name)
|
|
64
|
+
print(f"[OK] Default username saved: {args.set_name}")
|
|
65
|
+
sys.exit(0)
|
|
66
|
+
|
|
67
|
+
# Determine server URL
|
|
68
|
+
server_url = args.server or get_saved_server_url()
|
|
69
|
+
if not server_url:
|
|
70
|
+
print("Welcome to termchat!")
|
|
71
|
+
try:
|
|
72
|
+
val = input("Enter your online chat server URL (e.g. https://my-app.onrender.com): ").strip()
|
|
73
|
+
except (KeyboardInterrupt, EOFError):
|
|
74
|
+
print("\nAborted.")
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
if not val:
|
|
78
|
+
print("Error: A server URL is required.")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
set_saved_server_url(val)
|
|
82
|
+
server_url = val
|
|
83
|
+
print(f"Server saved to ~/.termchat.json. Next time you can just type 'termchat'!\n")
|
|
84
|
+
|
|
85
|
+
# Determine username
|
|
86
|
+
username = args.name or get_saved_username()
|
|
87
|
+
|
|
88
|
+
start(
|
|
89
|
+
server_url=server_url,
|
|
90
|
+
username=username,
|
|
91
|
+
auto_open=args.open,
|
|
92
|
+
no_thumb=args.no_thumb,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
main()
|
termchat/client.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal Chat Client for termchat.
|
|
3
|
+
Non-intrusive standard terminal interface (no UI takeover, standard terminal stream).
|
|
4
|
+
Sends entered text to online Render/cloud server and displays incoming text and images.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import platform
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.parse
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from prompt_toolkit import PromptSession
|
|
18
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
19
|
+
import websockets
|
|
20
|
+
|
|
21
|
+
from termchat.config import normalize_urls
|
|
22
|
+
from termchat.viewer import download_image, open_system_image, render_ansi_thumbnail
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TerminalClient:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
server_url: str,
|
|
29
|
+
username: str,
|
|
30
|
+
auto_open_images: bool = False,
|
|
31
|
+
show_thumbnails: bool = True,
|
|
32
|
+
):
|
|
33
|
+
self.raw_server_url = server_url
|
|
34
|
+
self.ws_url, self.http_base_url = normalize_urls(server_url)
|
|
35
|
+
self.username = username
|
|
36
|
+
self.auto_open_images = auto_open_images
|
|
37
|
+
self.show_thumbnails = show_thumbnails
|
|
38
|
+
self.running = True
|
|
39
|
+
self.websocket = None
|
|
40
|
+
self.last_downloaded_image: Optional[Path] = None
|
|
41
|
+
|
|
42
|
+
def print_welcome(self):
|
|
43
|
+
"""Prints a simple standard-line connection notice."""
|
|
44
|
+
print(f"\n--- termchat connected ---")
|
|
45
|
+
print(f"User: {self.username}")
|
|
46
|
+
print(f"Server: {self.http_base_url}")
|
|
47
|
+
print(f"Web UI: {self.http_base_url} (open in browser to chat or send images)")
|
|
48
|
+
print(f"Commands: /help, /open (view last image), /quit")
|
|
49
|
+
print(f"---------------------------\n")
|
|
50
|
+
|
|
51
|
+
def handle_incoming_chat(self, msg: dict):
|
|
52
|
+
sender = msg.get("sender", "Unknown")
|
|
53
|
+
client_type = msg.get("client_type", "web")
|
|
54
|
+
text = msg.get("text", "")
|
|
55
|
+
|
|
56
|
+
badge = "[๐ Web]" if client_type == "web" else "[๐ป Terminal]"
|
|
57
|
+
print(f"\n{badge} {sender}: {text}")
|
|
58
|
+
|
|
59
|
+
def handle_incoming_image(self, msg: dict):
|
|
60
|
+
sender = msg.get("sender", "Unknown")
|
|
61
|
+
rel_url = msg.get("image_url", "")
|
|
62
|
+
filename = msg.get("filename", "image.png")
|
|
63
|
+
|
|
64
|
+
# Construct full URL
|
|
65
|
+
full_url = f"{self.http_base_url}{rel_url}"
|
|
66
|
+
|
|
67
|
+
print(f"\n[๐ Web] {sender} ๐ท Sent an image: {filename}")
|
|
68
|
+
print(f" URL: {full_url}")
|
|
69
|
+
|
|
70
|
+
# Download locally
|
|
71
|
+
saved_path = download_image(full_url, filename)
|
|
72
|
+
if saved_path:
|
|
73
|
+
self.last_downloaded_image = saved_path
|
|
74
|
+
print(f" Saved locally: {saved_path}")
|
|
75
|
+
|
|
76
|
+
# Terminal ANSI thumbnail
|
|
77
|
+
if self.show_thumbnails:
|
|
78
|
+
thumb = render_ansi_thumbnail(saved_path, width=38)
|
|
79
|
+
if thumb:
|
|
80
|
+
print("\n" + thumb + "\n")
|
|
81
|
+
|
|
82
|
+
# Auto open if requested
|
|
83
|
+
if self.auto_open_images:
|
|
84
|
+
open_system_image(saved_path)
|
|
85
|
+
|
|
86
|
+
def handle_incoming_system(self, msg: dict):
|
|
87
|
+
text = msg.get("text", "")
|
|
88
|
+
print(f"\n[System] {text}")
|
|
89
|
+
|
|
90
|
+
async def _receive_loop(self, ws):
|
|
91
|
+
"""Listens for messages from the online server."""
|
|
92
|
+
try:
|
|
93
|
+
async for raw in ws:
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(raw)
|
|
96
|
+
except Exception:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
m_type = data.get("type")
|
|
100
|
+
|
|
101
|
+
if m_type == "chat":
|
|
102
|
+
self.handle_incoming_chat(data)
|
|
103
|
+
elif m_type == "image":
|
|
104
|
+
self.handle_incoming_image(data)
|
|
105
|
+
elif m_type == "system":
|
|
106
|
+
self.handle_incoming_system(data)
|
|
107
|
+
elif m_type == "history":
|
|
108
|
+
history = data.get("messages", [])
|
|
109
|
+
if history:
|
|
110
|
+
print(f"--- Loaded {len(history)} recent message(s) ---")
|
|
111
|
+
for h in history:
|
|
112
|
+
if h.get("type") == "chat":
|
|
113
|
+
self.handle_incoming_chat(h)
|
|
114
|
+
elif h.get("type") == "image":
|
|
115
|
+
self.handle_incoming_image(h)
|
|
116
|
+
print("------------------------------------------\n")
|
|
117
|
+
elif m_type == "ping":
|
|
118
|
+
await ws.send(json.dumps({"type": "pong"}))
|
|
119
|
+
|
|
120
|
+
except websockets.exceptions.ConnectionClosed:
|
|
121
|
+
if self.running:
|
|
122
|
+
print("\n[Server connection closed. Reconnecting...]")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
if self.running:
|
|
125
|
+
print(f"\n[Error receiving: {e}]")
|
|
126
|
+
|
|
127
|
+
async def _input_loop(self, session: PromptSession):
|
|
128
|
+
"""Reads user input and sends it to the server."""
|
|
129
|
+
while self.running:
|
|
130
|
+
try:
|
|
131
|
+
user_text = await session.prompt_async("> ")
|
|
132
|
+
text = user_text.strip()
|
|
133
|
+
if not text:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if text.startswith("/"):
|
|
137
|
+
cmd = text.lower().split()[0]
|
|
138
|
+
if cmd in ("/quit", "/exit", "/q"):
|
|
139
|
+
self.running = False
|
|
140
|
+
break
|
|
141
|
+
elif cmd == "/help":
|
|
142
|
+
print("\nAvailable commands:")
|
|
143
|
+
print(" /help - Show help")
|
|
144
|
+
print(" /open - Open the last received image in your OS viewer")
|
|
145
|
+
print(" /web - Print Web UI URL")
|
|
146
|
+
print(" /quit or /exit - Exit chat\n")
|
|
147
|
+
continue
|
|
148
|
+
elif cmd == "/web":
|
|
149
|
+
print(f"Web UI: {self.http_base_url}")
|
|
150
|
+
continue
|
|
151
|
+
elif cmd == "/open":
|
|
152
|
+
if self.last_downloaded_image and self.last_downloaded_image.exists():
|
|
153
|
+
print(f"Opening {self.last_downloaded_image}...")
|
|
154
|
+
open_system_image(self.last_downloaded_image)
|
|
155
|
+
else:
|
|
156
|
+
print("No images received yet.")
|
|
157
|
+
continue
|
|
158
|
+
else:
|
|
159
|
+
print(f"Unknown command '{cmd}'. Type /help for options.")
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
# Send message
|
|
163
|
+
if self.websocket:
|
|
164
|
+
payload = {
|
|
165
|
+
"type": "chat",
|
|
166
|
+
"text": text,
|
|
167
|
+
"timestamp": datetime.now().isoformat(),
|
|
168
|
+
}
|
|
169
|
+
await self.websocket.send(json.dumps(payload))
|
|
170
|
+
else:
|
|
171
|
+
print("[Not connected to server]")
|
|
172
|
+
|
|
173
|
+
except (KeyboardInterrupt, EOFError):
|
|
174
|
+
self.running = False
|
|
175
|
+
break
|
|
176
|
+
except Exception as e:
|
|
177
|
+
print(f"[Input error: {e}]")
|
|
178
|
+
|
|
179
|
+
async def run(self):
|
|
180
|
+
"""Main client loop with automatic reconnect."""
|
|
181
|
+
self.print_welcome()
|
|
182
|
+
session = PromptSession()
|
|
183
|
+
|
|
184
|
+
device = f"{platform.system()} ({platform.machine()})"
|
|
185
|
+
query_params = urllib.parse.urlencode({
|
|
186
|
+
"username": self.username,
|
|
187
|
+
"client_type": "terminal",
|
|
188
|
+
"device_info": device,
|
|
189
|
+
})
|
|
190
|
+
full_ws = f"{self.ws_url}?{query_params}"
|
|
191
|
+
|
|
192
|
+
with patch_stdout():
|
|
193
|
+
while self.running:
|
|
194
|
+
try:
|
|
195
|
+
print(f"Connecting to {self.http_base_url}...")
|
|
196
|
+
async with websockets.connect(
|
|
197
|
+
full_ws,
|
|
198
|
+
ping_interval=25,
|
|
199
|
+
ping_timeout=15,
|
|
200
|
+
) as ws:
|
|
201
|
+
self.websocket = ws
|
|
202
|
+
print("Connected! Start typing text below (Press Enter to send):\n")
|
|
203
|
+
|
|
204
|
+
recv_task = asyncio.create_task(self._receive_loop(ws))
|
|
205
|
+
input_task = asyncio.create_task(self._input_loop(session))
|
|
206
|
+
|
|
207
|
+
done, pending = await asyncio.wait(
|
|
208
|
+
[recv_task, input_task],
|
|
209
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
for t in pending:
|
|
213
|
+
t.cancel()
|
|
214
|
+
try:
|
|
215
|
+
await t
|
|
216
|
+
except asyncio.CancelledError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
if not self.running:
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
except (ConnectionRefusedError, OSError) as e:
|
|
223
|
+
if not self.running:
|
|
224
|
+
break
|
|
225
|
+
print(f"Could not connect to {self.http_base_url}. Retrying in 3s... (Ctrl+C to exit)")
|
|
226
|
+
await asyncio.sleep(3)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
if not self.running:
|
|
229
|
+
break
|
|
230
|
+
print(f"Connection error: {e}. Retrying in 3s...")
|
|
231
|
+
await asyncio.sleep(3)
|
|
232
|
+
|
|
233
|
+
print("\nExiting termchat. Goodbye!")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def start(
|
|
237
|
+
server_url: str,
|
|
238
|
+
username: str,
|
|
239
|
+
auto_open: bool = False,
|
|
240
|
+
no_thumb: bool = False,
|
|
241
|
+
):
|
|
242
|
+
client = TerminalClient(
|
|
243
|
+
server_url=server_url,
|
|
244
|
+
username=username,
|
|
245
|
+
auto_open_images=auto_open,
|
|
246
|
+
show_thumbnails=not no_thumb,
|
|
247
|
+
)
|
|
248
|
+
try:
|
|
249
|
+
asyncio.run(client.run())
|
|
250
|
+
except KeyboardInterrupt:
|
|
251
|
+
print("\nExited.")
|
termchat/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for termchat client.
|
|
3
|
+
Handles persistent user preferences in ~/.termchat.json, environment variables,
|
|
4
|
+
and URL normalization for WebSocket and HTTP endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
CONFIG_FILE = Path.home() / ".termchat.json"
|
|
13
|
+
DEFAULT_FALLBACK_SERVER = "http://localhost:8000"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config() -> Dict[str, str]:
|
|
17
|
+
"""Loads configuration from ~/.termchat.json if present."""
|
|
18
|
+
if CONFIG_FILE.exists():
|
|
19
|
+
try:
|
|
20
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_config(config: Dict[str, str]):
|
|
28
|
+
"""Persists configuration to ~/.termchat.json."""
|
|
29
|
+
try:
|
|
30
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
31
|
+
json.dump(config, f, indent=2)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"[Warning] Could not save config file: {e}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_saved_server_url() -> Optional[str]:
|
|
37
|
+
"""Retrieves server URL from ENV, config file, or None."""
|
|
38
|
+
# 1. Environment variable takes highest priority
|
|
39
|
+
env_url = os.environ.get("TERMCHAT_SERVER")
|
|
40
|
+
if env_url:
|
|
41
|
+
return env_url.strip()
|
|
42
|
+
|
|
43
|
+
# 2. Config file
|
|
44
|
+
cfg = load_config()
|
|
45
|
+
if "server" in cfg and cfg["server"]:
|
|
46
|
+
return cfg["server"].strip()
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def set_saved_server_url(url: str):
|
|
52
|
+
"""Saves default server URL to config file."""
|
|
53
|
+
cfg = load_config()
|
|
54
|
+
cfg["server"] = url.strip()
|
|
55
|
+
save_config(cfg)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_saved_username() -> str:
|
|
59
|
+
"""Retrieves saved username or falls back to system username."""
|
|
60
|
+
cfg = load_config()
|
|
61
|
+
if "username" in cfg and cfg["username"]:
|
|
62
|
+
return cfg["username"].strip()
|
|
63
|
+
return os.environ.get("USER") or os.environ.get("USERNAME") or "TermUser"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_saved_username(username: str):
|
|
67
|
+
"""Saves default username to config file."""
|
|
68
|
+
cfg = load_config()
|
|
69
|
+
cfg["username"] = username.strip()
|
|
70
|
+
save_config(cfg)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize_urls(raw_url: str):
|
|
74
|
+
"""
|
|
75
|
+
Given a user-provided URL (e.g. 'https://my-app.onrender.com' or 'localhost:8000'),
|
|
76
|
+
returns (ws_url, http_base_url).
|
|
77
|
+
"""
|
|
78
|
+
cleaned = raw_url.strip()
|
|
79
|
+
if not cleaned:
|
|
80
|
+
cleaned = DEFAULT_FALLBACK_SERVER
|
|
81
|
+
|
|
82
|
+
# Ensure protocol
|
|
83
|
+
if not cleaned.startswith("http://") and not cleaned.startswith("https://") and not cleaned.startswith("ws://") and not cleaned.startswith("wss://"):
|
|
84
|
+
if "localhost" in cleaned or "127.0.0.1" in cleaned:
|
|
85
|
+
cleaned = f"http://{cleaned}"
|
|
86
|
+
else:
|
|
87
|
+
cleaned = f"https://{cleaned}"
|
|
88
|
+
|
|
89
|
+
# Determine HTTP and WS protocols
|
|
90
|
+
if cleaned.startswith("https://"):
|
|
91
|
+
http_base = cleaned.rstrip("/")
|
|
92
|
+
ws_base = "wss://" + cleaned[8:].rstrip("/")
|
|
93
|
+
elif cleaned.startswith("http://"):
|
|
94
|
+
http_base = cleaned.rstrip("/")
|
|
95
|
+
ws_base = "ws://" + cleaned[7:].rstrip("/")
|
|
96
|
+
elif cleaned.startswith("wss://"):
|
|
97
|
+
http_base = "https://" + cleaned[6:].rstrip("/")
|
|
98
|
+
ws_base = cleaned.rstrip("/")
|
|
99
|
+
elif cleaned.startswith("ws://"):
|
|
100
|
+
http_base = "http://" + cleaned[5:].rstrip("/")
|
|
101
|
+
ws_base = cleaned.rstrip("/")
|
|
102
|
+
else:
|
|
103
|
+
http_base = f"https://{cleaned.rstrip('/')}"
|
|
104
|
+
ws_base = f"wss://{cleaned.rstrip('/')}"
|
|
105
|
+
|
|
106
|
+
# Strip /ws or trailing slashes from base
|
|
107
|
+
if http_base.endswith("/ws"):
|
|
108
|
+
http_base = http_base[:-3]
|
|
109
|
+
if ws_base.endswith("/ws"):
|
|
110
|
+
ws_base = ws_base[:-3]
|
|
111
|
+
|
|
112
|
+
ws_url = f"{ws_base}/ws"
|
|
113
|
+
return ws_url, http_base
|
termchat/viewer.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Image viewer and thumbnail renderer for terminal clients.
|
|
3
|
+
Handles automatic downloading, terminal ANSI color block preview,
|
|
4
|
+
and launching the OS default image viewer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import platform
|
|
11
|
+
import subprocess
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_downloads_dir() -> Path:
|
|
17
|
+
"""Returns local downloads directory."""
|
|
18
|
+
d = Path.cwd() / "downloads"
|
|
19
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def download_image(full_image_url: str, filename: str) -> Optional[Path]:
|
|
24
|
+
"""Downloads image to local downloads folder."""
|
|
25
|
+
try:
|
|
26
|
+
dest_dir = get_downloads_dir()
|
|
27
|
+
safe_name = Path(filename).name
|
|
28
|
+
dest_path = dest_dir / safe_name
|
|
29
|
+
|
|
30
|
+
req = urllib.request.Request(
|
|
31
|
+
full_image_url,
|
|
32
|
+
headers={"User-Agent": "termchat-terminal-client/1.0"},
|
|
33
|
+
)
|
|
34
|
+
with urllib.request.urlopen(req, timeout=10) as response, open(dest_path, "wb") as out_file:
|
|
35
|
+
out_file.write(response.read())
|
|
36
|
+
|
|
37
|
+
return dest_path
|
|
38
|
+
except Exception as e:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def open_system_image(filepath: Path):
|
|
43
|
+
"""Opens image in OS default viewer (Photos, Preview, or xdg-open)."""
|
|
44
|
+
try:
|
|
45
|
+
sys_name = platform.system()
|
|
46
|
+
if sys_name == "Windows":
|
|
47
|
+
os.startfile(str(filepath))
|
|
48
|
+
elif sys_name == "Darwin":
|
|
49
|
+
subprocess.run(["open", str(filepath)], check=False)
|
|
50
|
+
else:
|
|
51
|
+
subprocess.run(["xdg-open", str(filepath)], check=False)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_ansi_thumbnail(filepath: Path, width: int = 36) -> Optional[str]:
|
|
57
|
+
"""
|
|
58
|
+
Renders an image as an ANSI 24-bit color half-block thumbnail.
|
|
59
|
+
Each terminal character 'โ' has both foreground (top pixel) and background (bottom pixel).
|
|
60
|
+
Works in Windows Terminal, Linux, macOS, and Raspberry Pi consoles with 24-bit color support.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
from PIL import Image
|
|
64
|
+
|
|
65
|
+
img = Image.open(filepath).convert("RGB")
|
|
66
|
+
orig_w, orig_h = img.size
|
|
67
|
+
# Half blocks have 1:2 aspect ratio in terminal fonts
|
|
68
|
+
aspect = orig_h / orig_w
|
|
69
|
+
height = int(width * aspect * 0.5)
|
|
70
|
+
# Ensure even height for half-block pairing
|
|
71
|
+
if height % 2 != 0:
|
|
72
|
+
height += 1
|
|
73
|
+
height = max(height, 2)
|
|
74
|
+
|
|
75
|
+
resized = img.resize((width, height), Image.Resampling.BILINEAR)
|
|
76
|
+
pixels = resized.load()
|
|
77
|
+
|
|
78
|
+
lines = []
|
|
79
|
+
for y in range(0, height, 2):
|
|
80
|
+
line = []
|
|
81
|
+
for x in range(width):
|
|
82
|
+
r1, g1, b1 = pixels[x, y]
|
|
83
|
+
if y + 1 < height:
|
|
84
|
+
r2, g2, b2 = pixels[x, y + 1]
|
|
85
|
+
else:
|
|
86
|
+
r2, g2, b2 = (0, 0, 0)
|
|
87
|
+
# โ is upper half block: fg is top pixel, bg is bottom pixel
|
|
88
|
+
line.append(f"\033[38;2;{r1};{g1};{b1}m\033[48;2;{r2};{g2};{b2}mโ\033[0m")
|
|
89
|
+
lines.append("".join(line))
|
|
90
|
+
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
except Exception:
|
|
93
|
+
return None
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: termchat-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight, non-intrusive terminal chat client connecting to an online chat server with real-time text and image support.
|
|
5
|
+
Author-email: TermChat Team <info@termchat.dev>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/your-username/termchat
|
|
8
|
+
Project-URL: Repository, https://github.com/your-username/termchat
|
|
9
|
+
Project-URL: Issues, https://github.com/your-username/termchat/issues
|
|
10
|
+
Keywords: chat,terminal,cli,websocket,raspberry-pi,linux,windows,render,image-chat
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Communications :: Chat
|
|
27
|
+
Classifier: Topic :: Terminals
|
|
28
|
+
Requires-Python: >=3.8
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: websockets>=12.0
|
|
32
|
+
Requires-Dist: prompt_toolkit>=3.0.0
|
|
33
|
+
Requires-Dist: pillow>=9.0.0
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: httpx>=0.24.0; extra == "dev"
|
|
37
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
38
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
39
|
+
Requires-Dist: fastapi>=0.100.0; extra == "dev"
|
|
40
|
+
Requires-Dist: python-multipart>=0.0.6; extra == "dev"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# termchat-client ๐ฌ๐ผ๏ธ
|
|
44
|
+
|
|
45
|
+
[](https://pypi.org/project/termchat-client/)
|
|
46
|
+
[](https://pypi.org/project/termchat-client/)
|
|
47
|
+
[](https://opensource.org/licenses/MIT)
|
|
48
|
+
[](https://pypi.org/project/termchat-client/)
|
|
49
|
+
|
|
50
|
+
**termchat-client** is a lightweight, non-intrusive terminal chat client packaged for Python. It connects directly to an online chat server (running on **Render** or any cloud host), allowing you to chat seamlessly between your **terminal** and an **online HTML web page** viewed by others.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## ๐ก How It Works
|
|
55
|
+
|
|
56
|
+
- **Standard Terminal Interaction**: It does **not** change or take over your terminal UI. No fullscreen ncurses/curses screens. Just a normal, standard prompt line where you type text and press Enter.
|
|
57
|
+
- **Bi-Directional Messaging**:
|
|
58
|
+
- You type text in your terminal โ It appears instantly on the web page.
|
|
59
|
+
- Web users type replies โ They print right into your terminal: `[๐ WebUser]: Hello from Chrome!`
|
|
60
|
+
- **Image Support to Terminal**:
|
|
61
|
+
- Web users can click the image picker, drag & drop images, or **press `Ctrl+V` to paste screenshots directly from their clipboard**!
|
|
62
|
+
- When an image is sent from the web, your terminal automatically:
|
|
63
|
+
1. Downloads the image locally into `./downloads/`
|
|
64
|
+
2. Prints the image URL and filename
|
|
65
|
+
3. Renders a compact **ANSI 24-bit color block thumbnail preview** directly inside your terminal!
|
|
66
|
+
4. Can auto-open the image in your OS default image viewer with `--open` or `/open`.
|
|
67
|
+
- **Works on Any OS**: Windows (PowerShell / CMD / Windows Terminal), Linux (Debian, Ubuntu, Arch), **Raspberry Pi** (ARMv7 / aarch64), and macOS.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## ๐๏ธ System Architecture
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
75
|
+
โ Online Server on Render โ
|
|
76
|
+
โ (FastAPI + WebSockets + Image Upload API) โ
|
|
77
|
+
โ https://my-chat.onrender.com โ
|
|
78
|
+
โโโโโโโโโโโโโโโโฒโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฒโโโโโโโโโโโโโโโ
|
|
79
|
+
โ โ
|
|
80
|
+
WebSocket โ WebSocket โ + HTTP Web UI
|
|
81
|
+
Connection โ Connection โ (HTML5 / CSS / JS)
|
|
82
|
+
โ โ
|
|
83
|
+
โโโโโโโโโโโโโโโดโโโโโโโโโโโ โโโโโโโโโโโโโโโดโโโโโโโโโโโโโโ
|
|
84
|
+
โ termchat-client (CLI) โ โ Web Client (HTML) โ
|
|
85
|
+
โ โข Linux / Raspberry Piโ โ โข Desktop Browsers โ
|
|
86
|
+
โ โข Windows CMD / PS โ โ โข Mobile Browsers (iOS) โ
|
|
87
|
+
โ โข macOS Terminal โ โ โข Paste Images (Ctrl+V) โ
|
|
88
|
+
โ (Just text in & out) โ โ โข Drag & Drop Images โ
|
|
89
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## ๐ฆ Installation
|
|
95
|
+
|
|
96
|
+
Install the client package on any machine:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install termchat-client
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## ๐ Terminal Client Usage
|
|
105
|
+
|
|
106
|
+
### 1. Run via CLI
|
|
107
|
+
```bash
|
|
108
|
+
termchat --server https://my-chat.onrender.com --name Alice
|
|
109
|
+
```
|
|
110
|
+
Or via Python module:
|
|
111
|
+
```bash
|
|
112
|
+
python -m termchat --server https://my-chat.onrender.com --name Alice
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 2. Save Your Server Once
|
|
116
|
+
Save your online server URL so you never need to type it again:
|
|
117
|
+
```bash
|
|
118
|
+
termchat --set-server https://my-chat.onrender.com
|
|
119
|
+
termchat --set-name Alice
|
|
120
|
+
```
|
|
121
|
+
Once saved, simply run:
|
|
122
|
+
```bash
|
|
123
|
+
termchat
|
|
124
|
+
```
|
|
125
|
+
or:
|
|
126
|
+
```bash
|
|
127
|
+
python -m termchat
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 3. What It Looks Like in Your Terminal:
|
|
131
|
+
```text
|
|
132
|
+
--- termchat connected ---
|
|
133
|
+
User: Alice
|
|
134
|
+
Server: https://my-chat.onrender.com
|
|
135
|
+
Web UI: https://my-chat.onrender.com
|
|
136
|
+
Commands: /help, /open (view last image), /quit
|
|
137
|
+
---------------------------
|
|
138
|
+
|
|
139
|
+
Connected! Start typing text below (Press Enter to send):
|
|
140
|
+
|
|
141
|
+
> Hello from my Raspberry Pi terminal!
|
|
142
|
+
|
|
143
|
+
[๐ Web] Bob: Hey Alice! Check out this screenshot:
|
|
144
|
+
[๐ Web] Bob ๐ท Sent an image: dashboard.png
|
|
145
|
+
URL: https://my-chat.onrender.com/uploads/dashboard.png
|
|
146
|
+
Saved locally: C:\projects\terminal-chat\downloads\dashboard.png
|
|
147
|
+
|
|
148
|
+
[ANSI Image Thumbnail rendered here]
|
|
149
|
+
|
|
150
|
+
>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### In-Terminal Commands:
|
|
154
|
+
- `/open` โ Opens the last received image in your OS default image viewer (Photos, Preview, or `xdg-open`).
|
|
155
|
+
- `/web` โ Prints the Web UI link.
|
|
156
|
+
- `/help` โ Lists available commands.
|
|
157
|
+
- `/quit` (or `Ctrl+C`) โ Safely exits.
|
|
158
|
+
|
|
159
|
+
### Optional Flags:
|
|
160
|
+
- `--open`: Automatically opens any received image in your OS default image viewer.
|
|
161
|
+
- `--no-thumb`: Disables terminal ANSI thumbnail previews (prints URL and saved path only).
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## ๐ Deploying the Server to Render (Free)
|
|
166
|
+
|
|
167
|
+
The server code lives in the `server/` directory and can be deployed to [Render](https://render.com) in 2 minutes for free.
|
|
168
|
+
|
|
169
|
+
See [DEPLOY_RENDER.md](DEPLOY_RENDER.md) for full instructions:
|
|
170
|
+
1. Push this repository to GitHub.
|
|
171
|
+
2. In Render, click **New + > Web Service** and connect your GitHub repo.
|
|
172
|
+
3. Set **Root Directory** to `server`.
|
|
173
|
+
4. Set **Start Command** to `uvicorn main:app --host 0.0.0.0 --port $PORT`.
|
|
174
|
+
5. Deploy! Render gives you a live HTTPS and WebSocket URL like `https://my-chat.onrender.com`.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## ๐ค Publishing to PyPI
|
|
179
|
+
|
|
180
|
+
The package is packaged using modern PEP 517 / PEP 621 (`pyproject.toml`).
|
|
181
|
+
|
|
182
|
+
1. Build distributions:
|
|
183
|
+
```bash
|
|
184
|
+
python -m build
|
|
185
|
+
```
|
|
186
|
+
2. Verify with Twine:
|
|
187
|
+
```bash
|
|
188
|
+
twine check dist/*
|
|
189
|
+
```
|
|
190
|
+
3. Upload to PyPI:
|
|
191
|
+
```bash
|
|
192
|
+
twine upload dist/*
|
|
193
|
+
```
|
|
194
|
+
*(Or run the included interactive helper: `python publish.py`)*
|
|
195
|
+
|
|
196
|
+
See [PUBLISHING.md](PUBLISHING.md) for step-by-step instructions.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## ๐งช Testing
|
|
201
|
+
|
|
202
|
+
Run the automated test suite:
|
|
203
|
+
```bash
|
|
204
|
+
python -m pytest -v
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## ๐ License
|
|
210
|
+
|
|
211
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
termchat/__init__.py,sha256=m88TR9LiPzae8YdlWD04SlMfFVBDpVcAGRi_GPpADnM,271
|
|
2
|
+
termchat/__main__.py,sha256=oaDg29DTNudL4s_8Ivx88QtQ0nzHYuWEzuaSSn_d8mY,124
|
|
3
|
+
termchat/cli.py,sha256=F8S_0GDVpNK-Z4ov6BJNLbWai1EBm1bXVPyab3POG8Q,2653
|
|
4
|
+
termchat/client.py,sha256=86Twi978ZeZJP_piYegvCbsafJ4Q_NJIXntNXrq_8qA,9302
|
|
5
|
+
termchat/config.py,sha256=QE1da7cyCjvqqg5Nq_NbgCSV6QA9g4PYP0qBFfPzYWQ,3522
|
|
6
|
+
termchat/viewer.py,sha256=A07tiCx4aBZ3csA0lGy31Ivw91tPTxKs8SxHZ-aQDxQ,3002
|
|
7
|
+
termchat_client-0.1.0.dist-info/licenses/LICENSE,sha256=GfpbbMwTUxH_hvaVgaKPUjpWlWRmy1eVkfnmwHUymKc,1078
|
|
8
|
+
termchat_client-0.1.0.dist-info/METADATA,sha256=RfZFTpGpbHTES7YsZEgkStRTHQvLZa1541MnkPIsoo8,8429
|
|
9
|
+
termchat_client-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
termchat_client-0.1.0.dist-info/entry_points.txt,sha256=_eAa9jAnHvYF5uvfm4tlsB1YDo3sTiWUB6lZx0RPEzw,47
|
|
11
|
+
termchat_client-0.1.0.dist-info/top_level.txt,sha256=EiC1FPPpq71dNhAYr5hEIcqzqugtIP0HRdRD5-_oZVY,9
|
|
12
|
+
termchat_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TermChat Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
termchat
|