v6: markdown rendering, file uploads, colored TUI, multiline support
Web UI: - Textarea replaces input: Shift+Enter for newline, Enter to send - Pasted text preserves newlines, tabs, whitespace - Markdown: ```code blocks```, `inline code`, **bold**, *italic*, auto-links - File upload button (paperclip icon), files stored in memory with download links Python CLI client: - Colored usernames: green for self, cyan for system, unique color per other user - /file <path> command to upload files - Multiline messages displayed with continuation indent - JSON protocol for multiline + file support (backwards compatible) Server: - POST /chat/upload for multipart file uploads - GET /files/<id>/<name> for file downloads - TCP protocol accepts JSON packets for multiline text and file transfers - Falls back to plain text for old clients Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
335
chat.py
335
chat.py
@@ -6,6 +6,10 @@ Minimal multi-user chat. No dependencies beyond stdlib.
|
||||
Client: python3 chat.py <host> <name> [port]
|
||||
|
||||
The server also exposes a web UI at /chat (HTTP on the same port).
|
||||
|
||||
CLI client commands:
|
||||
/file <path> Upload a file to the chat
|
||||
/quit Disconnect
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -21,14 +25,16 @@ import html
|
||||
import urllib.parse
|
||||
|
||||
PORT = 9999
|
||||
VERSION = "5"
|
||||
VERSION = "6"
|
||||
TUNNEL_TARGET = ("185.208.174.152", 22)
|
||||
MAX_UPLOAD = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
# ── Server ──────────────────────────────────────────────────────────────
|
||||
|
||||
clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
|
||||
sse_queues: list[asyncio.Queue] = [] # web clients
|
||||
history: list[dict] = []
|
||||
uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes
|
||||
|
||||
|
||||
async def broadcast(msg: dict):
|
||||
@@ -70,24 +76,45 @@ CHAT_HTML = r"""<!DOCTYPE html>
|
||||
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Courier New', monospace;
|
||||
display: flex; flex-direction: column; height: 100vh; }
|
||||
#messages { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.msg { padding: 3px 0; }
|
||||
.msg { padding: 3px 0; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.msg code { background: #2a2a4a; padding: 1px 5px; border-radius: 3px; color: #f8c555; }
|
||||
.msg pre { background: #12122a; border: 1px solid #333; border-radius: 4px;
|
||||
padding: 8px; margin: 4px 0; overflow-x: auto; }
|
||||
.msg pre code { background: none; padding: 0; color: #e0e0e0; }
|
||||
.msg strong { color: #fff; }
|
||||
.msg em { color: #ccc; }
|
||||
.msg a.auto-link { color: #67c7eb; }
|
||||
.ts { color: #666; }
|
||||
.sys { color: #5e9ca0; font-style: italic; }
|
||||
#bottom { display: flex; padding: 8px; gap: 8px; border-top: 1px solid #333; background: #16213e; }
|
||||
.file-link { display: inline-block; background: #0f3460; border: 1px solid #444;
|
||||
padding: 4px 10px; border-radius: 4px; margin: 2px 0; color: #67c7eb;
|
||||
text-decoration: none; }
|
||||
.file-link:hover { background: #1a4a80; }
|
||||
#bottom { display: flex; padding: 8px; gap: 8px; border-top: 1px solid #333;
|
||||
background: #16213e; align-items: flex-end; }
|
||||
#name { width: 100px; padding: 8px; background: #0f3460; border: 1px solid #444;
|
||||
color: #e0e0e0; border-radius: 4px; }
|
||||
color: #e0e0e0; border-radius: 4px; align-self: flex-end; }
|
||||
#input { flex: 1; padding: 8px; background: #0f3460; border: 1px solid #444;
|
||||
color: #e0e0e0; border-radius: 4px; }
|
||||
color: #e0e0e0; border-radius: 4px; resize: none; min-height: 38px;
|
||||
max-height: 200px; font-family: inherit; font-size: inherit; line-height: 1.4; }
|
||||
#send { padding: 8px 16px; background: #e94560; border: none; color: #fff;
|
||||
border-radius: 4px; cursor: pointer; }
|
||||
border-radius: 4px; cursor: pointer; align-self: flex-end; }
|
||||
#send:hover { background: #c73e54; }
|
||||
#file-btn { padding: 8px 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0;
|
||||
border-radius: 4px; cursor: pointer; align-self: flex-end; font-size: 1.1em; }
|
||||
#file-btn:hover { background: #1a4a80; }
|
||||
#file-input { display: none; }
|
||||
.hint { color: #555; font-size: 0.75em; padding: 2px 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="messages"></div>
|
||||
<div class="hint">Shift+Enter for newline · Enter to send</div>
|
||||
<div id="bottom">
|
||||
<input id="name" placeholder="Name" value="">
|
||||
<input id="input" placeholder="Type a message…" autofocus autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
||||
<textarea id="input" placeholder="Type a message…" rows="1" autofocus
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
<label id="file-btn" title="Upload file">📎<input type="file" id="file-input"></label>
|
||||
<button id="send">Send</button>
|
||||
</div>
|
||||
<script>
|
||||
@@ -95,10 +122,10 @@ const $msg = document.getElementById('messages');
|
||||
const $input = document.getElementById('input');
|
||||
const $name = document.getElementById('name');
|
||||
const $send = document.getElementById('send');
|
||||
const $file = document.getElementById('file-input');
|
||||
|
||||
$name.value = 'user' + Math.floor(Math.random() * 1000);
|
||||
|
||||
// dark-mode-friendly palette for other users
|
||||
const USER_COLORS = [
|
||||
'#e6a23c', '#f56c9d', '#67c7eb', '#b39ddb',
|
||||
'#ff8a65', '#81c784', '#ce93d8', '#4fc3f7',
|
||||
@@ -106,31 +133,71 @@ const USER_COLORS = [
|
||||
];
|
||||
|
||||
function userColor(name) {
|
||||
// "my" user gets green, others get a stable color from the palette
|
||||
if (name === $name.value.trim()) return '#4ade80';
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
|
||||
return USER_COLORS[Math.abs(h) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderMd(raw) {
|
||||
// We escape first, then apply formatting on the escaped text.
|
||||
let s = esc(raw);
|
||||
|
||||
// Fenced code blocks: ```lang\ncode\n```
|
||||
s = s.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
|
||||
return '<pre><code>' + code + '</code></pre>';
|
||||
});
|
||||
// Also handle ``` without language or newline
|
||||
s = s.replace(/```([\s\S]*?)```/g, function(_, code) {
|
||||
return '<pre><code>' + code + '</code></pre>';
|
||||
});
|
||||
// Inline code: `...` (but not inside <pre>)
|
||||
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||
// Bold: **...**
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic: *...* (not preceded/followed by *)
|
||||
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
||||
// Auto-link URLs
|
||||
s = s.replace(/(https?:\/\/[^\s<&]+)/g, '<a class="auto-link" href="$1" target="_blank" rel="noopener">$1</a>');
|
||||
return s;
|
||||
}
|
||||
|
||||
function formatSize(n) {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1048576) return (n/1024).toFixed(1) + ' KB';
|
||||
return (n/1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function addMsg(data) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const t = new Date(data.ts * 1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
||||
if (data.user === '***') {
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span class="sys">' + esc(data.text) + '</span>';
|
||||
} else if (data.file_id) {
|
||||
const c = userColor(data.user);
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span style="color:' + c + ';font-weight:bold">'
|
||||
+ esc(data.user) + '</span>: '
|
||||
+ '<a class="file-link" href="/files/' + esc(data.file_id) + '/' + encodeURIComponent(data.filename)
|
||||
+ '" download="' + esc(data.filename) + '">📎 ' + esc(data.filename)
|
||||
+ ' (' + formatSize(data.file_size) + ')</a>';
|
||||
} else {
|
||||
const c = userColor(data.user);
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span style="color:' + c + ';font-weight:bold">' + esc(data.user) + '</span>: ' + esc(data.text);
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span style="color:' + c + ';font-weight:bold">'
|
||||
+ esc(data.user) + '</span>: ' + renderMd(data.text);
|
||||
}
|
||||
$msg.appendChild(d);
|
||||
$msg.scrollTop = $msg.scrollHeight;
|
||||
}
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function send() {
|
||||
const text = $input.value.trim();
|
||||
const text = $input.value.trimEnd();
|
||||
const name = $name.value.trim() || 'anon';
|
||||
if (!text) return;
|
||||
fetch('/chat/send', {
|
||||
@@ -139,10 +206,34 @@ function send() {
|
||||
body: 'name=' + encodeURIComponent(name) + '&text=' + encodeURIComponent(text)
|
||||
});
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
}
|
||||
|
||||
// auto-resize textarea
|
||||
$input.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||
});
|
||||
|
||||
$send.onclick = send;
|
||||
$input.onkeydown = function(e) { if (e.key === 'Enter') send(); };
|
||||
$input.onkeydown = function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
// file upload
|
||||
$file.onchange = function() {
|
||||
if (!this.files.length) return;
|
||||
const f = this.files[0];
|
||||
const name = $name.value.trim() || 'anon';
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('file', f);
|
||||
fetch('/chat/upload', { method: 'POST', body: fd });
|
||||
this.value = '';
|
||||
};
|
||||
|
||||
// SSE
|
||||
const es = new EventSource('/chat/events');
|
||||
@@ -153,6 +244,35 @@ es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Conne
|
||||
</html>
|
||||
"""
|
||||
|
||||
# ── Multipart parser (minimal, for file uploads) ───────────────────────
|
||||
|
||||
def parse_multipart(body: bytes, boundary: str) -> dict:
|
||||
"""Parse multipart/form-data, return {field_name: (filename|None, value)}."""
|
||||
parts = body.split(b"--" + boundary.encode())
|
||||
result = {}
|
||||
for part in parts:
|
||||
if not part or part.strip() in (b"", b"--"):
|
||||
continue
|
||||
if b"\r\n\r\n" not in part:
|
||||
continue
|
||||
header_block, content = part.split(b"\r\n\r\n", 1)
|
||||
if content.endswith(b"\r\n"):
|
||||
content = content[:-2]
|
||||
headers_str = header_block.decode(errors="replace")
|
||||
name = filename = None
|
||||
for h in headers_str.split("\r\n"):
|
||||
if "content-disposition" in h.lower():
|
||||
for kv in h.split(";"):
|
||||
kv = kv.strip()
|
||||
if kv.startswith("name="):
|
||||
name = kv.split("=", 1)[1].strip('"')
|
||||
if kv.startswith("filename="):
|
||||
filename = kv.split("=", 1)[1].strip('"')
|
||||
if name:
|
||||
result[name] = (filename, content)
|
||||
return result
|
||||
|
||||
|
||||
# ── HTTP request parser ────────────────────────────────────────────────
|
||||
|
||||
async def parse_http_request(reader: asyncio.StreamReader, first_line: str):
|
||||
@@ -336,7 +456,7 @@ async def handle_http(reader, writer, first_line):
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /chat/send — send a message from the web UI
|
||||
# POST /chat/send — send a message (supports multiline via JSON text field)
|
||||
if method == "POST" and path == "/chat/send":
|
||||
params = urllib.parse.parse_qs(body.decode())
|
||||
name = params.get("name", ["anon"])[0]
|
||||
@@ -348,6 +468,55 @@ async def handle_http(reader, writer, first_line):
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /chat/upload — file upload (multipart/form-data)
|
||||
if method == "POST" and path == "/chat/upload":
|
||||
ct = headers.get("content-type", "")
|
||||
if "multipart/form-data" in ct and "boundary=" in ct:
|
||||
boundary = ct.split("boundary=")[1].strip()
|
||||
fields = parse_multipart(body, boundary)
|
||||
name = fields.get("name", (None, b"anon"))[1]
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode()
|
||||
file_entry = fields.get("file")
|
||||
if file_entry and file_entry[0]:
|
||||
filename = file_entry[0]
|
||||
file_data = file_entry[1]
|
||||
if len(file_data) <= MAX_UPLOAD:
|
||||
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
|
||||
uploaded_files[file_id] = file_data
|
||||
await broadcast({
|
||||
"ts": time.time(), "user": name,
|
||||
"text": f"[file: {filename}]",
|
||||
"file_id": file_id, "filename": filename,
|
||||
"file_size": len(file_data)
|
||||
})
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /files/<id>/<filename> — download uploaded file
|
||||
if method == "GET" and path.startswith("/files/"):
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 3:
|
||||
file_id = parts[2]
|
||||
if file_id in uploaded_files:
|
||||
data = uploaded_files[file_id]
|
||||
fname = urllib.parse.unquote(parts[3]) if len(parts) > 3 else "file"
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: application/octet-stream\r\n")
|
||||
writer.write(f"Content-Length: {len(data)}\r\n".encode())
|
||||
writer.write(f"Content-Disposition: attachment; filename=\"{fname}\"\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /tunnel — WebSocket upgrade for SSH tunnel
|
||||
if method == "GET" and path == "/tunnel":
|
||||
ws_key = headers.get("sec-websocket-key", "")
|
||||
@@ -384,7 +553,7 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
await handle_http(reader, writer, first_line)
|
||||
return
|
||||
|
||||
# Raw TCP chat client
|
||||
# Raw TCP chat client — first line is the name
|
||||
name = first_line
|
||||
clients[writer] = name
|
||||
|
||||
@@ -400,9 +569,30 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
data = await reader.readline()
|
||||
if not data:
|
||||
break
|
||||
text = data.decode().strip()
|
||||
if text:
|
||||
await broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
line = data.decode().rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
# Try JSON (new protocol: supports multiline + files)
|
||||
try:
|
||||
pkt = json.loads(line)
|
||||
if pkt.get("type") == "file":
|
||||
file_data = base64.b64decode(pkt["data"])
|
||||
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
|
||||
uploaded_files[file_id] = file_data
|
||||
await broadcast({
|
||||
"ts": time.time(), "user": name,
|
||||
"text": f"[file: {pkt['filename']}]",
|
||||
"file_id": file_id, "filename": pkt["filename"],
|
||||
"file_size": len(file_data)
|
||||
})
|
||||
else:
|
||||
text = pkt.get("text", "").strip()
|
||||
if text:
|
||||
await broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# Legacy plain text (single line, backwards compat)
|
||||
if line.strip():
|
||||
await broadcast({"ts": time.time(), "user": name, "text": line.strip()})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
@@ -422,49 +612,114 @@ async def run_server(port: int):
|
||||
|
||||
# ── Client TUI ──────────────────────────────────────────────────────────
|
||||
|
||||
# Curses color pair assignments
|
||||
CP_SEPARATOR = 1
|
||||
CP_MY_NAME = 2
|
||||
CP_SYSTEM = 3
|
||||
CP_TIMESTAMP = 4
|
||||
# 10+ for other users
|
||||
OTHER_CURSES_COLORS = [
|
||||
curses.COLOR_YELLOW,
|
||||
curses.COLOR_MAGENTA,
|
||||
curses.COLOR_CYAN,
|
||||
curses.COLOR_RED,
|
||||
curses.COLOR_BLUE,
|
||||
curses.COLOR_WHITE,
|
||||
]
|
||||
|
||||
|
||||
def user_color_pair(username: str, my_name: str) -> int:
|
||||
"""Return curses color pair number for a username."""
|
||||
if username == "***":
|
||||
return CP_SYSTEM
|
||||
if username == my_name:
|
||||
return CP_MY_NAME
|
||||
h = 0
|
||||
for c in username:
|
||||
h = ((h << 5) - h + ord(c)) & 0xFFFFFFFF
|
||||
return 10 + (h % len(OTHER_CURSES_COLORS))
|
||||
|
||||
|
||||
class ChatClient:
|
||||
def __init__(self, host: str, name: str, port: int):
|
||||
self.host = host
|
||||
self.name = name
|
||||
self.port = port
|
||||
self.messages: list[str] = []
|
||||
self.messages: list[tuple[str, int]] = [] # (text, color_pair)
|
||||
self.input_buf = ""
|
||||
self.scroll = 0
|
||||
self.reader: asyncio.StreamReader | None = None
|
||||
self.writer: asyncio.StreamWriter | None = None
|
||||
self.running = True
|
||||
|
||||
def fmt(self, msg: dict) -> str:
|
||||
def add_message(self, msg: dict):
|
||||
t = time.strftime("%H:%M", time.localtime(msg["ts"]))
|
||||
cp = user_color_pair(msg["user"], self.name)
|
||||
if msg["user"] == "***":
|
||||
return f" {t} {msg['text']}"
|
||||
return f" {t} {msg['user']}: {msg['text']}"
|
||||
self.messages.append((f" {t} {msg['text']}", CP_SYSTEM))
|
||||
elif msg.get("file_id"):
|
||||
self.messages.append((
|
||||
f" {t} {msg['user']}: [file: {msg['filename']} ({msg['file_size']} bytes)]", cp))
|
||||
else:
|
||||
text = msg["text"]
|
||||
lines = text.split("\n")
|
||||
self.messages.append((f" {t} {msg['user']}: {lines[0]}", cp))
|
||||
for extra in lines[1:]:
|
||||
self.messages.append((f" {extra}", cp))
|
||||
|
||||
async def recv_loop(self):
|
||||
while self.running:
|
||||
line = await self.reader.readline()
|
||||
if not line:
|
||||
self.messages.append(" *** connection lost")
|
||||
self.messages.append((" *** connection lost", CP_SYSTEM))
|
||||
self.running = False
|
||||
break
|
||||
msg = json.loads(line.decode())
|
||||
self.messages.append(self.fmt(msg))
|
||||
self.add_message(msg)
|
||||
|
||||
async def send(self, text: str):
|
||||
self.writer.write((text + "\n").encode())
|
||||
async def send_json(self, pkt: dict):
|
||||
"""Send a JSON packet to the server."""
|
||||
self.writer.write((json.dumps(pkt) + "\n").encode())
|
||||
await self.writer.drain()
|
||||
|
||||
async def send_msg(self, text: str):
|
||||
"""Send a chat message (supports multiline)."""
|
||||
await self.send_json({"type": "msg", "text": text})
|
||||
|
||||
async def send_file(self, filepath: str):
|
||||
"""Upload a file to the chat."""
|
||||
filepath = os.path.expanduser(filepath)
|
||||
if not os.path.isfile(filepath):
|
||||
self.messages.append((f" *** file not found: {filepath}", CP_SYSTEM))
|
||||
return
|
||||
size = os.path.getsize(filepath)
|
||||
if size > MAX_UPLOAD:
|
||||
self.messages.append((f" *** file too large ({size} bytes, max {MAX_UPLOAD})", CP_SYSTEM))
|
||||
return
|
||||
with open(filepath, "rb") as f:
|
||||
data = f.read()
|
||||
await self.send_json({
|
||||
"type": "file",
|
||||
"filename": os.path.basename(filepath),
|
||||
"data": base64.b64encode(data).decode()
|
||||
})
|
||||
|
||||
async def run(self, stdscr):
|
||||
curses.curs_set(1)
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(2, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(CP_SEPARATOR, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(CP_MY_NAME, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(CP_SYSTEM, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(CP_TIMESTAMP, curses.COLOR_WHITE, -1)
|
||||
for i, color in enumerate(OTHER_CURSES_COLORS):
|
||||
curses.init_pair(10 + i, color, -1)
|
||||
stdscr.nodelay(True)
|
||||
stdscr.timeout(50)
|
||||
|
||||
self.reader, self.writer = await asyncio.open_connection(self.host, self.port)
|
||||
await self.send(self.name)
|
||||
# Send name as plain first line (server expects it)
|
||||
self.writer.write((self.name + "\n").encode())
|
||||
await self.writer.drain()
|
||||
|
||||
recv_task = asyncio.create_task(self.recv_loop())
|
||||
|
||||
@@ -473,18 +728,18 @@ class ChatClient:
|
||||
stdscr.erase()
|
||||
|
||||
sep_y = h - 2
|
||||
stdscr.addstr(sep_y, 0, "─" * w, curses.color_pair(1))
|
||||
stdscr.addstr(sep_y, 0, "─" * w, curses.color_pair(CP_SEPARATOR))
|
||||
|
||||
visible = self.messages[-(sep_y + self.scroll):len(self.messages) - self.scroll if self.scroll else None]
|
||||
for i, line in enumerate(visible[-(sep_y):]):
|
||||
for i, (line, cp) in enumerate(visible[-(sep_y):]):
|
||||
try:
|
||||
stdscr.addnstr(i, 0, line, w - 1)
|
||||
stdscr.addnstr(i, 0, line, w - 1, curses.color_pair(cp))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
prompt = f" {self.name}> "
|
||||
try:
|
||||
stdscr.addstr(h - 1, 0, prompt, curses.color_pair(2))
|
||||
stdscr.addstr(h - 1, 0, prompt, curses.color_pair(CP_MY_NAME))
|
||||
stdscr.addnstr(h - 1, len(prompt), self.input_buf, w - len(prompt) - 1)
|
||||
stdscr.move(h - 1, min(len(prompt) + len(self.input_buf), w - 1))
|
||||
except curses.error:
|
||||
@@ -501,16 +756,20 @@ class ChatClient:
|
||||
if isinstance(ch, str):
|
||||
if ch == "\n":
|
||||
if self.input_buf.strip():
|
||||
if self.input_buf.strip() == "/quit":
|
||||
cmd = self.input_buf.strip()
|
||||
if cmd == "/quit":
|
||||
break
|
||||
await self.send(self.input_buf)
|
||||
elif cmd.startswith("/file "):
|
||||
await self.send_file(cmd[6:].strip())
|
||||
else:
|
||||
await self.send_msg(self.input_buf)
|
||||
self.input_buf = ""
|
||||
self.scroll = 0
|
||||
elif ch == "\x7f" or ch == "\b":
|
||||
self.input_buf = self.input_buf[:-1]
|
||||
elif ch == "\x1b":
|
||||
pass
|
||||
elif ch.isprintable():
|
||||
elif ch.isprintable() or ch == "\t":
|
||||
self.input_buf += ch
|
||||
elif isinstance(ch, int):
|
||||
if ch == curses.KEY_BACKSPACE:
|
||||
|
||||
Reference in New Issue
Block a user