| … | … | @@ -0,0 +1,12 @@ |
| 1 | # Dependencies | |
| 2 | node_modules/ | |
| 3 | ||
| 4 | # Editor and OS cruft | |
| 5 | .DS_Store | |
| 6 | *.log | |
| 7 | *.swp | |
| 8 | .vscode/ | |
| 9 | .idea/ | |
| 10 | ||
| 11 | # Test artifacts | |
| 12 | coverage/ |
| … | … | @@ -0,0 +1,290 @@ |
| 1 | import { Program } from './lib/program.js' | |
| 2 | import { KeetClient } from './lib/client.js' | |
| 3 | import { mainView } from './lib/views/main.js' | |
| 4 | import { splashView } from './lib/views/splash.js' | |
| 5 | import { helpView } from './lib/views/help.js' | |
| 6 | import { handleInputKey } from './lib/components/input-box.js' | |
| 7 | ||
| 8 | // --- Parse args --- | |
| 9 | const rawArgs = Pear.config.args || [] | |
| 10 | let port = 3117 | |
| 11 | for (let i = 0; i < rawArgs.length; i++) { | |
| 12 | if (rawArgs[i] === '--port' && rawArgs[i + 1]) { | |
| 13 | port = parseInt(rawArgs[++i]) | |
| 14 | } else if (rawArgs[i].startsWith('--port=')) { | |
| 15 | port = parseInt(rawArgs[i].split('=')[1]) | |
| 16 | } | |
| 17 | } | |
| 18 | ||
| 19 | const client = new KeetClient({ port }) | |
| 20 | ||
| 21 | // --- Model --- | |
| 22 | const model = { | |
| 23 | // State | |
| 24 | screen: 'splash', // 'splash' | 'main' | |
| 25 | connected: false, | |
| 26 | error: null, | |
| 27 | port, | |
| 28 | ||
| 29 | // Identity | |
| 30 | identity: null, | |
| 31 | ||
| 32 | // Rooms | |
| 33 | rooms: [], | |
| 34 | selectedRoomIndex: 0, | |
| 35 | activeRoomId: null, | |
| 36 | ||
| 37 | // Chat | |
| 38 | messages: [], | |
| 39 | chatScroll: 0, | |
| 40 | inputBuffer: '', | |
| 41 | cursorPos: 0, | |
| 42 | lastMessageTimestamp: 0, | |
| 43 | ||
| 44 | // Members | |
| 45 | members: [], | |
| 46 | ||
| 47 | // Layout | |
| 48 | focus: 'chat', // 'rooms' | 'chat' | 'members' | |
| 49 | showHelp: false, | |
| 50 | width: 80, | |
| 51 | height: 24, | |
| 52 | ||
| 53 | // View dispatch | |
| 54 | view (w, h) { | |
| 55 | if (this.screen === 'splash') return splashView(this, w, h) | |
| 56 | if (this.showHelp) return helpView(this, w, h) | |
| 57 | return mainView(this, w, h) | |
| 58 | }, | |
| 59 | ||
| 60 | // MVU update | |
| 61 | update (msg) { | |
| 62 | switch (msg.type) { | |
| 63 | case 'resize': | |
| 64 | this.width = msg.width | |
| 65 | this.height = msg.height | |
| 66 | break | |
| 67 | ||
| 68 | case 'connected': | |
| 69 | this.screen = 'main' | |
| 70 | this.connected = true | |
| 71 | this.error = null | |
| 72 | if (msg.identity) this.identity = msg.identity | |
| 73 | if (msg.rooms) { | |
| 74 | this.rooms = msg.rooms | |
| 75 | if (this.rooms.length > 0 && !this.activeRoomId) { | |
| 76 | this.activeRoomId = this.rooms[0].roomId | |
| 77 | } | |
| 78 | } | |
| 79 | break | |
| 80 | ||
| 81 | case 'connection-error': | |
| 82 | this.connected = false | |
| 83 | this.error = msg.error | |
| 84 | break | |
| 85 | ||
| 86 | case 'rooms-updated': | |
| 87 | this.rooms = msg.rooms || [] | |
| 88 | // Keep selection in bounds | |
| 89 | if (this.selectedRoomIndex >= this.rooms.length) { | |
| 90 | this.selectedRoomIndex = Math.max(0, this.rooms.length - 1) | |
| 91 | } | |
| 92 | break | |
| 93 | ||
| 94 | case 'messages-updated': | |
| 95 | if (msg.messages && msg.messages.length > 0) { | |
| 96 | const lastTs = msg.messages[msg.messages.length - 1].timestamp || 0 | |
| 97 | const hadNew = lastTs > this.lastMessageTimestamp | |
| 98 | this.messages = msg.messages | |
| 99 | if (hadNew && this.chatScroll === 0) { | |
| 100 | this.lastMessageTimestamp = lastTs | |
| 101 | } | |
| 102 | } | |
| 103 | break | |
| 104 | ||
| 105 | case 'members-updated': | |
| 106 | this.members = msg.members || [] | |
| 107 | break | |
| 108 | ||
| 109 | case 'key': | |
| 110 | return this._handleKey(msg.key) | |
| 111 | } | |
| 112 | return null | |
| 113 | }, | |
| 114 | ||
| 115 | _handleKey (key) { | |
| 116 | // Splash screen: any key retries connection | |
| 117 | if (this.screen === 'splash') { | |
| 118 | this.error = null | |
| 119 | return null | |
| 120 | } | |
| 121 | ||
| 122 | // ? toggles help overlay | |
| 123 | if (key.sequence === '?') { | |
| 124 | this.showHelp = !this.showHelp | |
| 125 | return null | |
| 126 | } | |
| 127 | ||
| 128 | // Any key dismisses help when shown | |
| 129 | if (this.showHelp) { | |
| 130 | this.showHelp = false | |
| 131 | return null | |
| 132 | } | |
| 133 | ||
| 134 | // Tab: cycle focus | |
| 135 | if (key.name === 'tab') { | |
| 136 | if (key.shift) { | |
| 137 | this.focus = this.focus === 'rooms' ? 'members' | |
| 138 | : this.focus === 'chat' ? 'rooms' : 'chat' | |
| 139 | } else { | |
| 140 | this.focus = this.focus === 'rooms' ? 'chat' | |
| 141 | : this.focus === 'chat' ? 'members' : 'rooms' | |
| 142 | } | |
| 143 | return null | |
| 144 | } | |
| 145 | ||
| 146 | // Focus-specific handling | |
| 147 | if (this.focus === 'rooms') return this._handleRoomKey(key) | |
| 148 | if (this.focus === 'chat') return this._handleChatKey(key) | |
| 149 | return null | |
| 150 | }, | |
| 151 | ||
| 152 | _handleRoomKey (key) { | |
| 153 | if (key.name === 'up' || key.name === 'k') { | |
| 154 | if (this.selectedRoomIndex > 0) this.selectedRoomIndex-- | |
| 155 | return null | |
| 156 | } | |
| 157 | if (key.name === 'down' || key.name === 'j') { | |
| 158 | if (this.selectedRoomIndex < this.rooms.length - 1) this.selectedRoomIndex++ | |
| 159 | return null | |
| 160 | } | |
| 161 | if (key.name === 'return') { | |
| 162 | const room = this.rooms[this.selectedRoomIndex] | |
| 163 | if (room) { | |
| 164 | this.activeRoomId = room.roomId | |
| 165 | this.messages = [] | |
| 166 | this.chatScroll = 0 | |
| 167 | this.lastMessageTimestamp = 0 | |
| 168 | this.focus = 'chat' | |
| 169 | } | |
| 170 | return null | |
| 171 | } | |
| 172 | return null | |
| 173 | }, | |
| 174 | ||
| 175 | _handleChatKey (key) { | |
| 176 | // Page up/down for scrolling | |
| 177 | if (key.name === 'pageup') { | |
| 178 | this.chatScroll += 10 | |
| 179 | return null | |
| 180 | } | |
| 181 | if (key.name === 'pagedown') { | |
| 182 | this.chatScroll = Math.max(0, this.chatScroll - 10) | |
| 183 | return null | |
| 184 | } | |
| 185 | ||
| 186 | // Input handling | |
| 187 | const result = handleInputKey(this, key) | |
| 188 | if (result) { | |
| 189 | if (result.send) { | |
| 190 | const roomId = this.activeRoomId | |
| 191 | const text = result.send | |
| 192 | this.inputBuffer = result.inputBuffer | |
| 193 | this.cursorPos = result.cursorPos | |
| 194 | // Fire and forget send | |
| 195 | if (roomId) { | |
| 196 | client.send(roomId, text).catch(() => {}) | |
| 197 | } | |
| 198 | return null | |
| 199 | } | |
| 200 | if (result.inputBuffer !== undefined) this.inputBuffer = result.inputBuffer | |
| 201 | if (result.cursorPos !== undefined) this.cursorPos = result.cursorPos | |
| 202 | } | |
| 203 | return null | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | // --- Room title cache --- | |
| 208 | const titleCache = new Map() | |
| 209 | ||
| 210 | async function enrichRoomTitles (rooms) { | |
| 211 | for (const room of rooms) { | |
| 212 | if (room.title && room.title !== '(untitled)') continue | |
| 213 | if (titleCache.has(room.roomId)) { | |
| 214 | room.title = titleCache.get(room.roomId) | |
| 215 | continue | |
| 216 | } | |
| 217 | try { | |
| 218 | const config = await client.roomConfig(room.roomId) | |
| 219 | if (config.ok && config.data && config.data.title) { | |
| 220 | room.title = config.data.title | |
| 221 | titleCache.set(room.roomId, config.data.title) | |
| 222 | } | |
| 223 | } catch {} | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | // --- Polling --- | |
| 228 | async function onPoll (program) { | |
| 229 | try { | |
| 230 | if (!model.connected) { | |
| 231 | const alive = await client.ping() | |
| 232 | if (!alive) { | |
| 233 | program.dispatch({ type: 'connection-error', error: 'Cannot reach daemon' }) | |
| 234 | return | |
| 235 | } | |
| 236 | ||
| 237 | const [identity, roomsResult] = await Promise.all([ | |
| 238 | client.identity(), | |
| 239 | client.rooms() | |
| 240 | ]) | |
| 241 | ||
| 242 | const rooms = roomsResult.ok ? roomsResult.data : [] | |
| 243 | await enrichRoomTitles(rooms) | |
| 244 | ||
| 245 | program.dispatch({ | |
| 246 | type: 'connected', | |
| 247 | identity: identity.ok ? identity.data : null, | |
| 248 | rooms | |
| 249 | }) | |
| 250 | return | |
| 251 | } | |
| 252 | ||
| 253 | // Refresh rooms | |
| 254 | const roomsResult = await client.rooms() | |
| 255 | if (roomsResult.ok) { | |
| 256 | await enrichRoomTitles(roomsResult.data) | |
| 257 | program.dispatch({ type: 'rooms-updated', rooms: roomsResult.data }) | |
| 258 | } | |
| 259 | ||
| 260 | // Refresh messages for active room | |
| 261 | if (model.activeRoomId) { | |
| 262 | const msgsResult = await client.messages(model.activeRoomId, 50) | |
| 263 | if (msgsResult.ok) { | |
| 264 | program.dispatch({ type: 'messages-updated', messages: msgsResult.data }) | |
| 265 | } | |
| 266 | ||
| 267 | // Refresh members | |
| 268 | const membersResult = await client.members(model.activeRoomId) | |
| 269 | if (membersResult.ok) { | |
| 270 | program.dispatch({ type: 'members-updated', members: membersResult.data }) | |
| 271 | } | |
| 272 | } | |
| 273 | } catch (err) { | |
| 274 | if (model.connected) { | |
| 275 | model.connected = false | |
| 276 | model.screen = 'splash' | |
| 277 | model.error = err.message || 'Connection lost' | |
| 278 | } else { | |
| 279 | program.dispatch({ type: 'connection-error', error: err.message || 'Connection failed' }) | |
| 280 | } | |
| 281 | } | |
| 282 | } | |
| 283 | ||
| 284 | // --- Start --- | |
| 285 | const program = new Program(model, { | |
| 286 | pollInterval: 2000, | |
| 287 | onPoll | |
| 288 | }) | |
| 289 | ||
| 290 | program.run() |
| … | … | @@ -0,0 +1,90 @@ |
| 1 | #!/bin/bash | |
| 2 | # keet-bot.sh — Polls Keet for new messages and alerts via file | |
| 3 | # A Claude Code session can then read the inbox and respond | |
| 4 | # Usage: ./keet-bot.sh [roomId] | |
| 5 | ||
| 6 | ROOM_ID="${1:-ar5jd5tc3od5hbxxg6818pw9ikpgeq39y8ybgonyu9n179cdz8po}" | |
| 7 | PORT=3117 | |
| 8 | LOG="/tmp/keet-bot.log" | |
| 9 | INBOX="/tmp/keet-inbox" | |
| 10 | MY_MEMBER_ID="z9i4wpf3pmp3cwhh9nnwysbwxcgx1f8sminypcc63esbi8ih9iby" | |
| 11 | LAST_SEQ_FILE="/tmp/keet-bot-lastseq" | |
| 12 | POLL_INTERVAL=1 | |
| 13 | ||
| 14 | log() { | |
| 15 | local ts | |
| 16 | ts="$(date '+%Y-%m-%d %H:%M:%S')" | |
| 17 | echo "[$ts] $*" | tee -a "$LOG" | |
| 18 | } | |
| 19 | ||
| 20 | poll() { | |
| 21 | log "=== keet-bot started (polling mode) ===" | |
| 22 | log "Room: $ROOM_ID" | |
| 23 | log "Inbox: $INBOX" | |
| 24 | ||
| 25 | # Get current latest seq to skip history | |
| 26 | local init_data | |
| 27 | init_data=$(curl -s "http://localhost:${PORT}/rpc/messages/${ROOM_ID}?limit=1" 2>/dev/null) | |
| 28 | local last_seq | |
| 29 | last_seq=$(echo "$init_data" | python3 -c " | |
| 30 | import json, sys | |
| 31 | try: | |
| 32 | data = json.load(sys.stdin) | |
| 33 | msgs = data.get('data', []) | |
| 34 | if msgs: print(msgs[-1].get('seq', 0)) | |
| 35 | else: print(0) | |
| 36 | except: print(0) | |
| 37 | " 2>/dev/null) | |
| 38 | ||
| 39 | echo "$last_seq" > "$LAST_SEQ_FILE" | |
| 40 | : > "$INBOX" # clear inbox | |
| 41 | log "Starting from seq $last_seq" | |
| 42 | log "--- live ---" | |
| 43 | ||
| 44 | while true; do | |
| 45 | sleep "$POLL_INTERVAL" | |
| 46 | ||
| 47 | local saved_seq | |
| 48 | saved_seq=$(cat "$LAST_SEQ_FILE" 2>/dev/null || echo "0") | |
| 49 | ||
| 50 | local result | |
| 51 | result=$(curl -s "http://localhost:${PORT}/rpc/messages/${ROOM_ID}?limit=10" 2>/dev/null) | |
| 52 | [ -z "$result" ] && continue | |
| 53 | ||
| 54 | # Find new messages from others (process substitution avoids subshell) | |
| 55 | while IFS=$'\t' read -r type seq name text; do | |
| 56 | if [ "$type" = "MSG" ]; then | |
| 57 | local ts | |
| 58 | ts="$(date '+%H:%M')" | |
| 59 | log ">> $name: $text" | |
| 60 | echo "[$ts] $name: $text" >> "$INBOX" | |
| 61 | elif [ "$type" = "SEQ" ]; then | |
| 62 | echo "$seq" > "$LAST_SEQ_FILE" | |
| 63 | fi | |
| 64 | done < <(python3 -c " | |
| 65 | import json, sys | |
| 66 | try: | |
| 67 | data = json.loads(sys.argv[1]) | |
| 68 | last_seq = int(sys.argv[2]) | |
| 69 | my_id = sys.argv[3] | |
| 70 | msgs = data.get('data', []) | |
| 71 | max_seq = last_seq | |
| 72 | for m in msgs: | |
| 73 | seq = m.get('seq', 0) | |
| 74 | if seq > max_seq: max_seq = seq | |
| 75 | if seq <= last_seq: continue | |
| 76 | member_id = m.get('memberId', '') | |
| 77 | if member_id == my_id: continue | |
| 78 | name = m.get('name') or member_id[:8] | |
| 79 | text = m.get('text', '') | |
| 80 | system = m.get('system') | |
| 81 | if system or not text: continue | |
| 82 | print(f'MSG\t{seq}\t{name}\t{text}') | |
| 83 | print(f'SEQ\t{max_seq}') | |
| 84 | except: | |
| 85 | pass | |
| 86 | " "$result" "$saved_seq" "$MY_MEMBER_ID" 2>/dev/null) | |
| 87 | done | |
| 88 | } | |
| 89 | ||
| 90 | poll |
| … | … | @@ -0,0 +1,54 @@ |
| 1 | #!/bin/bash | |
| 2 | # keet-watch.sh — persistent watcher for Keet messages via SSE | |
| 3 | # Usage: ./keet-watch.sh [roomId] | |
| 4 | # Logs to /tmp/keet-watch.log and stdout | |
| 5 | ||
| 6 | ROOM_ID="${1:-ar5jd5tc3od5hbxxg6818pw9ikpgeq39y8ybgonyu9n179cdz8po}" | |
| 7 | PORT=3117 | |
| 8 | LOG="/tmp/keet-watch.log" | |
| 9 | ||
| 10 | log() { | |
| 11 | local ts | |
| 12 | ts="$(date '+%Y-%m-%d %H:%M:%S')" | |
| 13 | echo "[$ts] $*" | tee -a "$LOG" | |
| 14 | } | |
| 15 | ||
| 16 | watch_room() { | |
| 17 | log "=== keet watcher started for room $ROOM_ID ===" | |
| 18 | ||
| 19 | while true; do | |
| 20 | log "Connecting to SSE stream on :${PORT}..." | |
| 21 | ||
| 22 | curl -sN "http://localhost:${PORT}/rpc/watch/${ROOM_ID}" 2>/dev/null | while IFS= read -r line; do | |
| 23 | case "$line" in | |
| 24 | "event: ready") | |
| 25 | log "--- live ---" | |
| 26 | ;; | |
| 27 | "data: "*) | |
| 28 | data="${line#data: }" | |
| 29 | # Extract name and text with python (available everywhere) | |
| 30 | parsed=$(python3 -c " | |
| 31 | import json, sys | |
| 32 | try: | |
| 33 | m = json.loads(sys.argv[1]) | |
| 34 | name = m.get('name') or (m.get('memberId','???')[:8]) | |
| 35 | text = m.get('text','') | |
| 36 | system = m.get('system') | |
| 37 | if system: | |
| 38 | print(f'* system event') | |
| 39 | elif text: | |
| 40 | print(f'{name}: {text}') | |
| 41 | except: | |
| 42 | pass | |
| 43 | " "$data" 2>/dev/null) | |
| 44 | [ -n "$parsed" ] && log "$parsed" | |
| 45 | ;; | |
| 46 | esac | |
| 47 | done | |
| 48 | ||
| 49 | log "Stream ended, reconnecting in 5s..." | |
| 50 | sleep 5 | |
| 51 | done | |
| 52 | } | |
| 53 | ||
| 54 | watch_room |
| … | … | @@ -0,0 +1,92 @@ |
| 1 | import http from 'bare-http1' | |
| 2 | ||
| 3 | export class KeetClient { | |
| 4 | constructor (opts = {}) { | |
| 5 | this.port = opts.port || 3117 | |
| 6 | this.host = opts.host || 'localhost' | |
| 7 | } | |
| 8 | ||
| 9 | async call (method, params = {}) { | |
| 10 | const result = await this._post('/rpc', { method, params }) | |
| 11 | return result | |
| 12 | } | |
| 13 | ||
| 14 | async rooms () { | |
| 15 | return this._get('/rpc/rooms') | |
| 16 | } | |
| 17 | ||
| 18 | async messages (roomId, limit = 30) { | |
| 19 | return this._get(`/rpc/messages/${encodeURIComponent(roomId)}?limit=${limit}`) | |
| 20 | } | |
| 21 | ||
| 22 | async send (roomId, text) { | |
| 23 | return this.call('send', { roomId, text }) | |
| 24 | } | |
| 25 | ||
| 26 | async members (roomId) { | |
| 27 | return this._get(`/rpc/members/${encodeURIComponent(roomId)}`) | |
| 28 | } | |
| 29 | ||
| 30 | async roomConfig (roomId) { | |
| 31 | return this.call('room.config', { roomId }) | |
| 32 | } | |
| 33 | ||
| 34 | async identity () { | |
| 35 | return this._get('/rpc/identity') | |
| 36 | } | |
| 37 | ||
| 38 | async status () { | |
| 39 | return this._get('/rpc/status') | |
| 40 | } | |
| 41 | ||
| 42 | async ping () { | |
| 43 | try { | |
| 44 | const result = await this.status() | |
| 45 | return result.ok === true | |
| 46 | } catch { | |
| 47 | return false | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | _get (path) { | |
| 52 | return this._request('GET', path) | |
| 53 | } | |
| 54 | ||
| 55 | _post (path, body) { | |
| 56 | return this._request('POST', path, body) | |
| 57 | } | |
| 58 | ||
| 59 | _request (method, urlPath, body) { | |
| 60 | return new Promise((resolve, reject) => { | |
| 61 | const headers = {} | |
| 62 | let payload = null | |
| 63 | if (body) { | |
| 64 | payload = JSON.stringify(body) | |
| 65 | headers['Content-Type'] = 'application/json' | |
| 66 | headers['Content-Length'] = Buffer.byteLength(payload) | |
| 67 | } | |
| 68 | ||
| 69 | const req = http.request({ | |
| 70 | hostname: this.host, | |
| 71 | port: this.port, | |
| 72 | path: urlPath, | |
| 73 | method, | |
| 74 | headers | |
| 75 | }, (res) => { | |
| 76 | let data = '' | |
| 77 | res.on('data', (chunk) => { data += chunk.toString() }) | |
| 78 | res.on('end', () => { | |
| 79 | try { | |
| 80 | resolve(JSON.parse(data)) | |
| 81 | } catch { | |
| 82 | resolve({ ok: false, error: 'Invalid JSON', raw: data }) | |
| 83 | } | |
| 84 | }) | |
| 85 | }) | |
| 86 | ||
| 87 | req.on('error', reject) | |
| 88 | if (payload) req.write(payload) | |
| 89 | req.end() | |
| 90 | }) | |
| 91 | } | |
| 92 | } |
| … | … | @@ -0,0 +1,129 @@ |
| 1 | import * as style from '../style.js' | |
| 2 | ||
| 3 | const SYSTEM_EVENTS = { | |
| 4 | 1: 'joined', 2: 'call started', 3: 'avatar changed', | |
| 5 | 4: 'invite created', 5: 'left', 6: 'title changed', | |
| 6 | 7: 'removed', 8: 'call ended', 9: 'call missed' | |
| 7 | } | |
| 8 | ||
| 9 | export function chatView (model, width, height) { | |
| 10 | const lines = [] | |
| 11 | const room = model.rooms[model.selectedRoomIndex] | |
| 12 | ||
| 13 | // Header | |
| 14 | const title = room ? (room.title || room.roomId.slice(0, 16)) : 'No room selected' | |
| 15 | lines.push(style.bold(style.truncate(' ' + title, width))) | |
| 16 | lines.push(style.hline(width, 'rounded')) | |
| 17 | ||
| 18 | if (!room) { | |
| 19 | lines.push(style.dim(style.center('Select a room to start chatting', width))) | |
| 20 | while (lines.length < height) lines.push('') | |
| 21 | return lines | |
| 22 | } | |
| 23 | ||
| 24 | // Messages area (height - header(2) - separator(1) - input(2)) | |
| 25 | const msgHeight = height - 5 | |
| 26 | const messages = model.messages || [] | |
| 27 | ||
| 28 | const wrapped = [] | |
| 29 | for (const msg of messages) { | |
| 30 | const formatted = formatMessage(msg, width - 1) | |
| 31 | for (const line of formatted) { | |
| 32 | wrapped.push(line) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | // Scroll from bottom: show most recent messages | |
| 37 | const scrollPos = model.chatScroll || 0 | |
| 38 | const start = Math.max(0, wrapped.length - msgHeight - scrollPos) | |
| 39 | const end = Math.max(0, wrapped.length - scrollPos) | |
| 40 | const visible = wrapped.slice(start, end) | |
| 41 | ||
| 42 | // Pad top if fewer messages than space, then fill with visible messages | |
| 43 | if (visible.length < msgHeight) { | |
| 44 | const pad = msgHeight - visible.length | |
| 45 | for (let i = 0; i < pad; i++) lines.push('') | |
| 46 | } | |
| 47 | for (const l of visible.slice(0, msgHeight)) lines.push(l) | |
| 48 | ||
| 49 | // Input separator | |
| 50 | lines.push(style.hline(width, 'rounded')) | |
| 51 | ||
| 52 | // Input line | |
| 53 | const prompt = style.cyan('> ') | |
| 54 | const inputText = model.inputBuffer || '' | |
| 55 | const cursorPos = model.cursorPos != null ? model.cursorPos : inputText.length | |
| 56 | const maxInput = width - 3 | |
| 57 | ||
| 58 | let displayInput = inputText | |
| 59 | if (displayInput.length > maxInput) { | |
| 60 | const start = Math.max(0, cursorPos - maxInput + 1) | |
| 61 | displayInput = displayInput.slice(start, start + maxInput) | |
| 62 | } | |
| 63 | ||
| 64 | lines.push(prompt + style.padRight(displayInput, maxInput)) | |
| 65 | ||
| 66 | while (lines.length < height) lines.push('') | |
| 67 | return lines | |
| 68 | } | |
| 69 | ||
| 70 | function formatSystemEvent (system) { | |
| 71 | if (typeof system === 'number') { | |
| 72 | return SYSTEM_EVENTS[system] || 'event ' + system | |
| 73 | } | |
| 74 | if (typeof system === 'object' && system !== null) { | |
| 75 | for (const [key, val] of Object.entries(system)) { | |
| 76 | if (val === null || val === false) continue | |
| 77 | const label = key.replace(/([A-Z])/g, ' $1').toLowerCase().trim() | |
| 78 | if (typeof val === 'object' && val.from && val.to) { | |
| 79 | return label + ': ' + val.from + ' \u2192 ' + val.to | |
| 80 | } | |
| 81 | if (typeof val === 'object' && val.memberId) { | |
| 82 | return label | |
| 83 | } | |
| 84 | return label | |
| 85 | } | |
| 86 | } | |
| 87 | return 'system event' | |
| 88 | } | |
| 89 | ||
| 90 | function formatMessage (msg, width) { | |
| 91 | if (msg.system) { | |
| 92 | const name = formatSystemEvent(msg.system) | |
| 93 | return [style.dim(style.center('--- ' + name + ' ---', width))] | |
| 94 | } | |
| 95 | ||
| 96 | const time = msg.timestamp | |
| 97 | ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) | |
| 98 | : '??:??' | |
| 99 | ||
| 100 | const sender = msg.name || (msg.memberId ? msg.memberId.slice(0, 8) : '???') | |
| 101 | const deleted = msg.deleted ? style.dim(' [deleted]') : '' | |
| 102 | const header = style.brightBlack(time + ' ') + style.bold(style.cyan(sender)) + deleted | |
| 103 | ||
| 104 | const text = msg.text || '' | |
| 105 | if (!text) return [header] | |
| 106 | ||
| 107 | const lines = [header] | |
| 108 | // Split on newlines first, then wrap each line | |
| 109 | const paragraphs = text.split('\n') | |
| 110 | for (const para of paragraphs) { | |
| 111 | if (!para) { | |
| 112 | lines.push('') | |
| 113 | continue | |
| 114 | } | |
| 115 | const words = para.split(' ') | |
| 116 | let current = ' ' | |
| 117 | for (const word of words) { | |
| 118 | if (current.length + word.length + 1 > width && current.length > 1) { | |
| 119 | lines.push(current) | |
| 120 | current = ' ' + word | |
| 121 | } else { | |
| 122 | current += (current.length > 1 ? ' ' : '') + word | |
| 123 | } | |
| 124 | } | |
| 125 | if (current.length > 1) lines.push(current) | |
| 126 | } | |
| 127 | ||
| 128 | return lines | |
| 129 | } |
| … | … | @@ -0,0 +1,82 @@ |
| 1 | export function handleInputKey (model, key) { | |
| 2 | const buf = model.inputBuffer || '' | |
| 3 | const pos = model.cursorPos != null ? model.cursorPos : buf.length | |
| 4 | ||
| 5 | if (key.name === 'return') { | |
| 6 | if (buf.trim()) { | |
| 7 | return { send: buf.trim(), inputBuffer: '', cursorPos: 0 } | |
| 8 | } | |
| 9 | return null | |
| 10 | } | |
| 11 | ||
| 12 | if (key.name === 'backspace') { | |
| 13 | if (pos > 0) { | |
| 14 | return { | |
| 15 | inputBuffer: buf.slice(0, pos - 1) + buf.slice(pos), | |
| 16 | cursorPos: pos - 1 | |
| 17 | } | |
| 18 | } | |
| 19 | return null | |
| 20 | } | |
| 21 | ||
| 22 | if (key.name === 'delete') { | |
| 23 | if (pos < buf.length) { | |
| 24 | return { | |
| 25 | inputBuffer: buf.slice(0, pos) + buf.slice(pos + 1), | |
| 26 | cursorPos: pos | |
| 27 | } | |
| 28 | } | |
| 29 | return null | |
| 30 | } | |
| 31 | ||
| 32 | if (key.name === 'left') { | |
| 33 | if (pos > 0) return { cursorPos: pos - 1 } | |
| 34 | return null | |
| 35 | } | |
| 36 | ||
| 37 | if (key.name === 'right') { | |
| 38 | if (pos < buf.length) return { cursorPos: pos + 1 } | |
| 39 | return null | |
| 40 | } | |
| 41 | ||
| 42 | if (key.name === 'home' || (key.name === 'a' && key.ctrl)) { | |
| 43 | return { cursorPos: 0 } | |
| 44 | } | |
| 45 | ||
| 46 | if (key.name === 'end' || (key.name === 'e' && key.ctrl)) { | |
| 47 | return { cursorPos: buf.length } | |
| 48 | } | |
| 49 | ||
| 50 | // Ctrl+U: clear line | |
| 51 | if (key.name === 'u' && key.ctrl) { | |
| 52 | return { inputBuffer: '', cursorPos: 0 } | |
| 53 | } | |
| 54 | ||
| 55 | // Ctrl+W: delete word back | |
| 56 | if (key.name === 'w' && key.ctrl) { | |
| 57 | const before = buf.slice(0, pos) | |
| 58 | const trimmed = before.replace(/\s*\S+\s*$/, '') | |
| 59 | return { | |
| 60 | inputBuffer: trimmed + buf.slice(pos), | |
| 61 | cursorPos: trimmed.length | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | // Regular character input | |
| 66 | if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= ' ') { | |
| 67 | return { | |
| 68 | inputBuffer: buf.slice(0, pos) + key.sequence + buf.slice(pos), | |
| 69 | cursorPos: pos + 1 | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | // Multi-byte chars (emoji, unicode) | |
| 74 | if (!key.ctrl && !key.meta && key.sequence && key.sequence.length > 1 && !key.sequence.startsWith('\x1b')) { | |
| 75 | return { | |
| 76 | inputBuffer: buf.slice(0, pos) + key.sequence + buf.slice(pos), | |
| 77 | cursorPos: pos + key.sequence.length | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | return null | |
| 82 | } |
| … | … | @@ -0,0 +1,44 @@ |
| 1 | import * as style from '../style.js' | |
| 2 | ||
| 3 | export function memberListView (model, width, height) { | |
| 4 | const lines = [] | |
| 5 | const members = model.members || [] | |
| 6 | ||
| 7 | lines.push(style.bold(style.center(' Members ', width))) | |
| 8 | lines.push(style.hline(width, 'rounded')) | |
| 9 | ||
| 10 | if (members.length === 0) { | |
| 11 | lines.push(style.dim(style.center('---', width))) | |
| 12 | while (lines.length < height) lines.push('') | |
| 13 | return lines | |
| 14 | } | |
| 15 | ||
| 16 | const listHeight = height - 3 | |
| 17 | ||
| 18 | // Online first, then by name | |
| 19 | const sorted = [...members].sort((a, b) => { | |
| 20 | if (a.online && !b.online) return -1 | |
| 21 | if (!a.online && b.online) return 1 | |
| 22 | return (a.name || '').localeCompare(b.name || '') | |
| 23 | }) | |
| 24 | ||
| 25 | for (let i = 0; i < listHeight; i++) { | |
| 26 | if (i >= sorted.length) { | |
| 27 | lines.push('') | |
| 28 | continue | |
| 29 | } | |
| 30 | const m = sorted[i] | |
| 31 | const name = m.name || (m.memberId ? m.memberId.slice(0, 8) : '???') | |
| 32 | const dot = m.online ? style.green('\u25cf') : style.brightBlack('\u25cb') | |
| 33 | const role = m.role === 'admin' ? style.yellow(' \u2605') : '' | |
| 34 | const line = ' ' + dot + ' ' + style.truncate(name, width - 6) + role | |
| 35 | lines.push(style.padRight(line, width)) | |
| 36 | } | |
| 37 | ||
| 38 | // Count | |
| 39 | const online = members.filter(m => m.online).length | |
| 40 | lines.push(style.dim(style.center(online + '/' + members.length + ' online', width))) | |
| 41 | ||
| 42 | while (lines.length < height) lines.push('') | |
| 43 | return lines | |
| 44 | } |
| … | … | @@ -0,0 +1,71 @@ |
| 1 | import * as style from '../style.js' | |
| 2 | ||
| 3 | export function roomListView (model, width, height) { | |
| 4 | const rooms = model.rooms || [] | |
| 5 | const selected = model.selectedRoomIndex | |
| 6 | const lines = [] | |
| 7 | ||
| 8 | // Title | |
| 9 | lines.push(style.bold(style.center(' Rooms ', width))) | |
| 10 | lines.push(style.hline(width, 'rounded')) | |
| 11 | ||
| 12 | if (rooms.length === 0) { | |
| 13 | lines.push(style.dim(style.center('No rooms', width))) | |
| 14 | while (lines.length < height) lines.push('') | |
| 15 | return lines | |
| 16 | } | |
| 17 | ||
| 18 | const listHeight = height - 3 // title + separator + bottom | |
| 19 | const scrollOffset = computeScroll(selected, rooms.length, listHeight) | |
| 20 | ||
| 21 | for (let i = 0; i < listHeight; i++) { | |
| 22 | const idx = scrollOffset + i | |
| 23 | if (idx >= rooms.length) { | |
| 24 | lines.push('') | |
| 25 | continue | |
| 26 | } | |
| 27 | ||
| 28 | const room = rooms[idx] | |
| 29 | const title = room.title || room.roomId.slice(0, 12) | |
| 30 | const isSelected = idx === selected | |
| 31 | const isActive = room.roomId === model.activeRoomId | |
| 32 | ||
| 33 | let line = ' ' | |
| 34 | if (isActive) line += style.green('\u25cf') + ' ' | |
| 35 | else line += ' ' | |
| 36 | ||
| 37 | line += style.truncate(title, width - 4) | |
| 38 | ||
| 39 | if (room.unread) { | |
| 40 | line = style.padRight(line, width - 4) | |
| 41 | line += style.bold(style.yellow(' ' + room.unread)) | |
| 42 | } | |
| 43 | ||
| 44 | line = style.padRight(line, width) | |
| 45 | ||
| 46 | if (isSelected) { | |
| 47 | line = style.invert(line) | |
| 48 | } | |
| 49 | ||
| 50 | lines.push(line) | |
| 51 | } | |
| 52 | ||
| 53 | // Scroll indicator | |
| 54 | if (rooms.length > listHeight) { | |
| 55 | const pct = Math.round((scrollOffset / Math.max(1, rooms.length - listHeight)) * 100) | |
| 56 | lines.push(style.dim(style.center(pct + '%', width))) | |
| 57 | } else { | |
| 58 | lines.push('') | |
| 59 | } | |
| 60 | ||
| 61 | while (lines.length < height) lines.push('') | |
| 62 | return lines | |
| 63 | } | |
| 64 | ||
| 65 | function computeScroll (selected, total, viewHeight) { | |
| 66 | if (total <= viewHeight) return 0 | |
| 67 | let offset = selected - Math.floor(viewHeight / 2) | |
| 68 | if (offset < 0) offset = 0 | |
| 69 | if (offset > total - viewHeight) offset = total - viewHeight | |
| 70 | return offset | |
| 71 | } |
| … | … | @@ -0,0 +1,39 @@ |
| 1 | import * as style from '../style.js' | |
| 2 | ||
| 3 | export function statusBarView (model, width) { | |
| 4 | const parts = [] | |
| 5 | ||
| 6 | // Connection status | |
| 7 | if (model.connected) { | |
| 8 | parts.push(style.green('\u25cf') + ' Connected') | |
| 9 | } else { | |
| 10 | parts.push(style.red('\u25cf') + ' Disconnected') | |
| 11 | } | |
| 12 | ||
| 13 | // Identity | |
| 14 | if (model.identity) { | |
| 15 | const name = model.identity.name || model.identity.memberId?.slice(0, 8) || '?' | |
| 16 | parts.push(style.cyan(name)) | |
| 17 | } | |
| 18 | ||
| 19 | // Room count | |
| 20 | const roomCount = (model.rooms || []).length | |
| 21 | parts.push(roomCount + ' room' + (roomCount !== 1 ? 's' : '')) | |
| 22 | ||
| 23 | // Focus mode | |
| 24 | const mode = model.focus === 'rooms' ? '[Rooms]' : model.focus === 'chat' ? '[Chat]' : '[Members]' | |
| 25 | parts.push(style.dim(mode)) | |
| 26 | ||
| 27 | // Help hint | |
| 28 | const help = style.bold(style.yellow('?:help')) + style.dim(' Tab:switch Ctrl+C:quit') | |
| 29 | ||
| 30 | const left = ' ' + parts.join(style.brightBlack(' \u2502 ')) | |
| 31 | const right = style.dim(help) + ' ' | |
| 32 | ||
| 33 | const leftLen = style.visibleLength(left) | |
| 34 | const rightLen = style.visibleLength(right) | |
| 35 | const gap = Math.max(1, width - leftLen - rightLen) | |
| 36 | ||
| 37 | const bar = left + ' '.repeat(gap) + right | |
| 38 | return style.bg256(236, style.padRight(bar, width)) | |
| 39 | } |
| … | … | @@ -0,0 +1,91 @@ |
| 1 | import { Screen } from './screen.js' | |
| 2 | ||
| 3 | export class Program { | |
| 4 | constructor (model, opts = {}) { | |
| 5 | this.model = model | |
| 6 | this.screen = new Screen() | |
| 7 | this.running = false | |
| 8 | this._pollInterval = opts.pollInterval || 2000 | |
| 9 | this._pollTimer = null | |
| 10 | this._onPoll = opts.onPoll || null | |
| 11 | } | |
| 12 | ||
| 13 | run () { | |
| 14 | this.running = true | |
| 15 | ||
| 16 | this.screen.enterAltScreen() | |
| 17 | this.screen.hideCursor() | |
| 18 | this.screen.enableRawMode() | |
| 19 | ||
| 20 | this.screen.on('key', (key) => this.dispatch({ type: 'key', key })) | |
| 21 | this.screen.on('resize', (size) => { | |
| 22 | this.dispatch({ type: 'resize', ...size }) | |
| 23 | }) | |
| 24 | ||
| 25 | // Initial size into model | |
| 26 | this.dispatch({ | |
| 27 | type: 'resize', | |
| 28 | width: this.screen.width, | |
| 29 | height: this.screen.height | |
| 30 | }) | |
| 31 | ||
| 32 | this.render() | |
| 33 | this._startPoll() | |
| 34 | } | |
| 35 | ||
| 36 | dispatch (msg) { | |
| 37 | if (!this.running) return | |
| 38 | ||
| 39 | // Ctrl+C always quits | |
| 40 | if (msg.type === 'key' && msg.key.name === 'c' && msg.key.ctrl) { | |
| 41 | this.quit() | |
| 42 | return | |
| 43 | } | |
| 44 | ||
| 45 | const result = this.model.update(msg) | |
| 46 | if (result && result.quit) { | |
| 47 | this.quit() | |
| 48 | return | |
| 49 | } | |
| 50 | ||
| 51 | this.render() | |
| 52 | } | |
| 53 | ||
| 54 | render () { | |
| 55 | if (!this.running) return | |
| 56 | const w = this.screen.width | |
| 57 | const h = this.screen.height | |
| 58 | const lines = this.model.view(w, h) | |
| 59 | this.screen.render(lines) | |
| 60 | } | |
| 61 | ||
| 62 | quit () { | |
| 63 | if (!this.running) return | |
| 64 | this.running = false | |
| 65 | this._stopPoll() | |
| 66 | this.screen.destroy() | |
| 67 | Bare.exit(0) | |
| 68 | } | |
| 69 | ||
| 70 | _startPoll () { | |
| 71 | if (!this._onPoll) return | |
| 72 | const tick = async () => { | |
| 73 | if (!this.running) return | |
| 74 | try { | |
| 75 | await this._onPoll(this) | |
| 76 | this.render() | |
| 77 | } catch {} | |
| 78 | if (this.running) { | |
| 79 | this._pollTimer = setTimeout(tick, this._pollInterval) | |
| 80 | } | |
| 81 | } | |
| 82 | this._pollTimer = setTimeout(tick, 100) | |
| 83 | } | |
| 84 | ||
| 85 | _stopPoll () { | |
| 86 | if (this._pollTimer) { | |
| 87 | clearTimeout(this._pollTimer) | |
| 88 | this._pollTimer = null | |
| 89 | } | |
| 90 | } | |
| 91 | } |
| … | … | @@ -0,0 +1,104 @@ |
| 1 | import tty from 'bare-tty' | |
| 2 | import ansi from 'bare-ansi-escapes' | |
| 3 | import KeyDecoder from 'bare-ansi-escapes/key-decoder' | |
| 4 | import { EventEmitter } from 'events' | |
| 5 | ||
| 6 | const ALT_SCREEN_ON = '\x1b[?1049h' | |
| 7 | const ALT_SCREEN_OFF = '\x1b[?1049l' | |
| 8 | ||
| 9 | export class Screen extends EventEmitter { | |
| 10 | constructor () { | |
| 11 | super() | |
| 12 | if (!tty.isTTY(0) || !tty.isTTY(1)) { | |
| 13 | throw new Error('keet-tui requires a terminal (TTY). Run interactively, not piped.') | |
| 14 | } | |
| 15 | this._stdin = new tty.ReadStream(0) | |
| 16 | this._stdout = new tty.WriteStream(1) | |
| 17 | this._decoder = new KeyDecoder() | |
| 18 | this._raw = false | |
| 19 | this._alt = false | |
| 20 | this._lastFrame = null | |
| 21 | } | |
| 22 | ||
| 23 | get width () { | |
| 24 | return this._stdout.columns | |
| 25 | } | |
| 26 | ||
| 27 | get height () { | |
| 28 | return this._stdout.rows | |
| 29 | } | |
| 30 | ||
| 31 | enterAltScreen () { | |
| 32 | if (this._alt) return | |
| 33 | this._alt = true | |
| 34 | this._write(ALT_SCREEN_ON) | |
| 35 | } | |
| 36 | ||
| 37 | exitAltScreen () { | |
| 38 | if (!this._alt) return | |
| 39 | this._alt = false | |
| 40 | this._write(ALT_SCREEN_OFF) | |
| 41 | } | |
| 42 | ||
| 43 | enableRawMode () { | |
| 44 | if (this._raw) return | |
| 45 | this._raw = true | |
| 46 | this._stdin.setRawMode(true) | |
| 47 | ||
| 48 | this._decoder.on('data', (key) => { | |
| 49 | this.emit('key', key) | |
| 50 | }) | |
| 51 | ||
| 52 | this._stdin.pipe(this._decoder) | |
| 53 | ||
| 54 | this._stdout.on('resize', () => { | |
| 55 | this.emit('resize', { width: this.width, height: this.height }) | |
| 56 | }) | |
| 57 | } | |
| 58 | ||
| 59 | disableRawMode () { | |
| 60 | if (!this._raw) return | |
| 61 | this._raw = false | |
| 62 | if (typeof this._stdin.unpipe === 'function') { | |
| 63 | this._stdin.unpipe(this._decoder) | |
| 64 | } | |
| 65 | this._stdin.setRawMode(false) | |
| 66 | } | |
| 67 | ||
| 68 | hideCursor () { | |
| 69 | this._write(ansi.cursorHide) | |
| 70 | } | |
| 71 | ||
| 72 | showCursor () { | |
| 73 | this._write(ansi.cursorShow) | |
| 74 | } | |
| 75 | ||
| 76 | render (lines) { | |
| 77 | const frame = lines.join('\n') | |
| 78 | if (frame === this._lastFrame) return | |
| 79 | this._lastFrame = frame | |
| 80 | ||
| 81 | let buf = ansi.cursorPosition(0, 0) | |
| 82 | const h = this.height | |
| 83 | for (let i = 0; i < h; i++) { | |
| 84 | buf += ansi.cursorPosition(0, i) | |
| 85 | buf += ansi.eraseLine | |
| 86 | if (i < lines.length) { | |
| 87 | buf += lines[i] | |
| 88 | } | |
| 89 | } | |
| 90 | this._write(buf) | |
| 91 | } | |
| 92 | ||
| 93 | destroy () { | |
| 94 | this.showCursor() | |
| 95 | this.exitAltScreen() | |
| 96 | this.disableRawMode() | |
| 97 | this._stdin.destroy() | |
| 98 | this._stdout.end() | |
| 99 | } | |
| 100 | ||
| 101 | _write (data) { | |
| 102 | this._stdout.write(data) | |
| 103 | } | |
| 104 | } |
| … | … | @@ -0,0 +1,133 @@ |
| 1 | import ansi from 'bare-ansi-escapes' | |
| 2 | ||
| 3 | const { SGR } = ansi.constants | |
| 4 | ||
| 5 | // --- Text styling (wraps string with SGR codes) --- | |
| 6 | ||
| 7 | export function bold (s) { return ansi.modifierBold + s + ansi.modifierNormal } | |
| 8 | export function dim (s) { return ansi.modifierDim + s + ansi.modifierNormal } | |
| 9 | export function italic (s) { return ansi.modifierItalic + s + ansi.modifierNotItalic } | |
| 10 | export function underline (s) { return ansi.modifierUnderline + s + ansi.modifierNotUnderline } | |
| 11 | ||
| 12 | export function red (s) { return ansi.colorRed + s + ansi.colorDefault } | |
| 13 | export function green (s) { return ansi.colorGreen + s + ansi.colorDefault } | |
| 14 | export function yellow (s) { return ansi.colorYellow + s + ansi.colorDefault } | |
| 15 | export function blue (s) { return ansi.colorBlue + s + ansi.colorDefault } | |
| 16 | export function magenta (s) { return ansi.colorMagenta + s + ansi.colorDefault } | |
| 17 | export function cyan (s) { return ansi.colorCyan + s + ansi.colorDefault } | |
| 18 | export function white (s) { return ansi.colorWhite + s + ansi.colorDefault } | |
| 19 | export function brightBlack (s) { return ansi.colorBrightBlack + s + ansi.colorDefault } | |
| 20 | export function brightCyan (s) { return ansi.colorBrightCyan + s + ansi.colorDefault } | |
| 21 | export function brightGreen (s) { return ansi.colorBrightGreen + s + ansi.colorDefault } | |
| 22 | export function brightYellow (s) { return ansi.colorBrightYellow + s + ansi.colorDefault } | |
| 23 | ||
| 24 | export function bg256 (n, s) { return SGR('48;5;' + n) + s + SGR(49) } | |
| 25 | export function fg256 (n, s) { return SGR('38;5;' + n) + s + SGR(39) } | |
| 26 | ||
| 27 | export function invert (s) { return SGR(7) + s + SGR(27) } | |
| 28 | ||
| 29 | // --- String measurement (strips ANSI) --- | |
| 30 | ||
| 31 | const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g | |
| 32 | ||
| 33 | export function stripAnsi (s) { | |
| 34 | return s.replace(ANSI_RE, '') | |
| 35 | } | |
| 36 | ||
| 37 | export function visibleLength (s) { | |
| 38 | return stripAnsi(s).length | |
| 39 | } | |
| 40 | ||
| 41 | // --- Layout helpers --- | |
| 42 | ||
| 43 | export function truncate (s, maxWidth) { | |
| 44 | if (maxWidth <= 0) return '' | |
| 45 | const stripped = stripAnsi(s) | |
| 46 | if (stripped.length <= maxWidth) return s | |
| 47 | ||
| 48 | // Walk through, tracking visible chars | |
| 49 | let visible = 0 | |
| 50 | let result = '' | |
| 51 | let i = 0 | |
| 52 | const raw = s | |
| 53 | while (i < raw.length && visible < maxWidth - 1) { | |
| 54 | if (raw[i] === '\x1b') { | |
| 55 | const end = raw.indexOf('m', i) | |
| 56 | if (end !== -1) { | |
| 57 | result += raw.slice(i, end + 1) | |
| 58 | i = end + 1 | |
| 59 | continue | |
| 60 | } | |
| 61 | } | |
| 62 | result += raw[i] | |
| 63 | visible++ | |
| 64 | i++ | |
| 65 | } | |
| 66 | return result + ansi.modifierReset + '\u2026' | |
| 67 | } | |
| 68 | ||
| 69 | export function padRight (s, width) { | |
| 70 | const vl = visibleLength(s) | |
| 71 | if (vl >= width) return s | |
| 72 | return s + ' '.repeat(width - vl) | |
| 73 | } | |
| 74 | ||
| 75 | export function padLeft (s, width) { | |
| 76 | const vl = visibleLength(s) | |
| 77 | if (vl >= width) return s | |
| 78 | return ' '.repeat(width - vl) + s | |
| 79 | } | |
| 80 | ||
| 81 | export function center (s, width) { | |
| 82 | const vl = visibleLength(s) | |
| 83 | if (vl >= width) return s | |
| 84 | const left = Math.floor((width - vl) / 2) | |
| 85 | const right = width - vl - left | |
| 86 | return ' '.repeat(left) + s + ' '.repeat(right) | |
| 87 | } | |
| 88 | ||
| 89 | // --- Box drawing --- | |
| 90 | ||
| 91 | const BOX = { | |
| 92 | rounded: { tl: '\u256d', tr: '\u256e', bl: '\u2570', br: '\u256f', h: '\u2500', v: '\u2502' }, | |
| 93 | single: { tl: '\u250c', tr: '\u2510', bl: '\u2514', br: '\u2518', h: '\u2500', v: '\u2502' }, | |
| 94 | double: { tl: '\u2554', tr: '\u2557', bl: '\u255a', br: '\u255d', h: '\u2550', v: '\u2551' }, | |
| 95 | heavy: { tl: '\u250f', tr: '\u2513', bl: '\u2517', br: '\u251b', h: '\u2501', v: '\u2503' } | |
| 96 | } | |
| 97 | ||
| 98 | export function hline (width, style = 'single') { | |
| 99 | const ch = (BOX[style] || BOX.single).h | |
| 100 | return ch.repeat(width) | |
| 101 | } | |
| 102 | ||
| 103 | export function vline (style = 'single') { | |
| 104 | return (BOX[style] || BOX.single).v | |
| 105 | } | |
| 106 | ||
| 107 | export function box (lines, width, opts = {}) { | |
| 108 | const style = opts.border || 'rounded' | |
| 109 | const title = opts.title || '' | |
| 110 | const b = BOX[style] || BOX.rounded | |
| 111 | ||
| 112 | const inner = width - 2 | |
| 113 | if (inner <= 0) return lines.map(() => '') | |
| 114 | ||
| 115 | let topLine = b.tl | |
| 116 | if (title) { | |
| 117 | const t = ' ' + title + ' ' | |
| 118 | topLine += t + b.h.repeat(Math.max(0, inner - t.length)) | |
| 119 | } else { | |
| 120 | topLine += b.h.repeat(inner) | |
| 121 | } | |
| 122 | topLine += b.tr | |
| 123 | ||
| 124 | const bottom = b.bl + b.h.repeat(inner) + b.br | |
| 125 | ||
| 126 | const result = [topLine] | |
| 127 | for (const line of lines) { | |
| 128 | const content = truncate(padRight(line, inner), inner) | |
| 129 | result.push(b.v + content + b.v) | |
| 130 | } | |
| 131 | result.push(bottom) | |
| 132 | return result | |
| 133 | } |
| … | … | @@ -0,0 +1,71 @@ |
| 1 | import * as style from '../style.js' | |
| 2 | ||
| 3 | const HELP = [ | |
| 4 | ['', style.bold('Navigation')], | |
| 5 | ['Tab / Shift+Tab', 'Cycle focus: Rooms → Chat → Members'], | |
| 6 | ['', ''], | |
| 7 | ['', style.bold('Room List (when focused)')], | |
| 8 | ['\u2191 / \u2193 or j / k', 'Move selection up / down'], | |
| 9 | ['Enter', 'Open selected room'], | |
| 10 | ['', ''], | |
| 11 | ['', style.bold('Chat (when focused)')], | |
| 12 | ['Type + Enter', 'Send a message'], | |
| 13 | ['Page Up / Page Down', 'Scroll message history'], | |
| 14 | ['\u2190 / \u2192', 'Move cursor in input'], | |
| 15 | ['Home / Ctrl+A', 'Cursor to start of line'], | |
| 16 | ['End / Ctrl+E', 'Cursor to end of line'], | |
| 17 | ['Ctrl+W', 'Delete word backward'], | |
| 18 | ['Ctrl+U', 'Clear input line'], | |
| 19 | ['Backspace / Delete', 'Delete character'], | |
| 20 | ['', ''], | |
| 21 | ['', style.bold('Global')], | |
| 22 | ['?', 'Toggle this help screen'], | |
| 23 | ['Ctrl+C', 'Quit'] | |
| 24 | ] | |
| 25 | ||
| 26 | export function helpView (model, width, height) { | |
| 27 | const lines = [] | |
| 28 | const boxWidth = Math.min(60, width - 4) | |
| 29 | const boxLeft = Math.floor((width - boxWidth) / 2) | |
| 30 | const pad = ' '.repeat(boxLeft) | |
| 31 | ||
| 32 | const contentHeight = HELP.length + 4 // title + borders + bottom | |
| 33 | const topPad = Math.max(1, Math.floor((height - contentHeight) / 2)) | |
| 34 | ||
| 35 | for (let i = 0; i < topPad; i++) lines.push('') | |
| 36 | ||
| 37 | // Top border | |
| 38 | const inner = boxWidth - 2 | |
| 39 | const title = ' Keybindings ' | |
| 40 | const titlePad = Math.max(0, inner - title.length) | |
| 41 | const leftBar = Math.floor(titlePad / 2) | |
| 42 | const rightBar = titlePad - leftBar | |
| 43 | lines.push(pad + style.cyan('\u256d' + '\u2500'.repeat(leftBar) + title + '\u2500'.repeat(rightBar) + '\u256e')) | |
| 44 | ||
| 45 | // Content rows | |
| 46 | for (const [key, desc] of HELP) { | |
| 47 | let row | |
| 48 | if (!key && desc) { | |
| 49 | // Section header | |
| 50 | row = ' ' + desc | |
| 51 | } else if (!key && !desc) { | |
| 52 | // Blank line | |
| 53 | row = '' | |
| 54 | } else { | |
| 55 | const keyCol = style.bold(style.yellow(style.padRight(key, 22))) | |
| 56 | row = ' ' + keyCol + desc | |
| 57 | } | |
| 58 | const rowContent = style.truncate(row, inner) | |
| 59 | lines.push(pad + style.cyan('\u2502') + style.padRight(rowContent, inner) + style.cyan('\u2502')) | |
| 60 | } | |
| 61 | ||
| 62 | // Bottom border with dismiss hint | |
| 63 | const hint = ' Press ? to close ' | |
| 64 | const hintPad = Math.max(0, inner - hint.length) | |
| 65 | const hintLeft = Math.floor(hintPad / 2) | |
| 66 | const hintRight = hintPad - hintLeft | |
| 67 | lines.push(pad + style.cyan('\u2570' + '\u2500'.repeat(hintLeft) + hint + '\u2500'.repeat(hintRight) + '\u256f')) | |
| 68 | ||
| 69 | while (lines.length < height) lines.push('') | |
| 70 | return lines | |
| 71 | } |
| … | … | @@ -0,0 +1,49 @@ |
| 1 | import { roomListView } from '../components/room-list.js' | |
| 2 | import { chatView } from '../components/chat-view.js' | |
| 3 | import { memberListView } from '../components/member-list.js' | |
| 4 | import { statusBarView } from '../components/status-bar.js' | |
| 5 | import * as style from '../style.js' | |
| 6 | ||
| 7 | export function mainView (model, width, height) { | |
| 8 | // Layout: [rooms sidebar] | [chat] | [members sidebar] | |
| 9 | // Status bar at bottom | |
| 10 | const bodyHeight = height - 1 // 1 for status bar | |
| 11 | ||
| 12 | // Column widths | |
| 13 | const showMembers = width > 80 | |
| 14 | const sidebarWidth = Math.min(24, Math.floor(width * 0.2)) | |
| 15 | const memberWidth = showMembers ? Math.min(20, Math.floor(width * 0.15)) : 0 | |
| 16 | const chatWidth = width - sidebarWidth - memberWidth - (showMembers ? 2 : 1) // separators | |
| 17 | ||
| 18 | // Render columns | |
| 19 | const roomLines = roomListView(model, sidebarWidth, bodyHeight) | |
| 20 | const chatLines = chatView(model, chatWidth, bodyHeight) | |
| 21 | const memberLines = showMembers ? memberListView(model, memberWidth, bodyHeight) : null | |
| 22 | ||
| 23 | const sep = style.vline('single') | |
| 24 | const focusSep = style.bold(style.cyan(style.vline('heavy'))) | |
| 25 | ||
| 26 | // Compose rows | |
| 27 | const lines = [] | |
| 28 | for (let i = 0; i < bodyHeight; i++) { | |
| 29 | const roomPart = style.padRight(roomLines[i] || '', sidebarWidth) | |
| 30 | const chatPart = style.padRight(chatLines[i] || '', chatWidth) | |
| 31 | ||
| 32 | let row = roomPart | |
| 33 | row += (model.focus === 'rooms' ? focusSep : sep) | |
| 34 | row += chatPart | |
| 35 | ||
| 36 | if (showMembers) { | |
| 37 | const memberPart = style.padRight(memberLines[i] || '', memberWidth) | |
| 38 | row += (model.focus === 'members' ? focusSep : sep) | |
| 39 | row += memberPart | |
| 40 | } | |
| 41 | ||
| 42 | lines.push(row) | |
| 43 | } | |
| 44 | ||
| 45 | // Status bar | |
| 46 | lines.push(statusBarView(model, width)) | |
| 47 | ||
| 48 | return lines | |
| 49 | } |
| … | … | @@ -0,0 +1,25 @@ |
| 1 | import * as style from '../style.js' | |
| 2 | ||
| 3 | export function splashView (model, width, height) { | |
| 4 | const lines = [] | |
| 5 | const mid = Math.floor(height / 2) | |
| 6 | ||
| 7 | for (let i = 0; i < mid - 3; i++) lines.push('') | |
| 8 | ||
| 9 | lines.push(style.center(style.bold(style.cyan('keet-tui')), width)) | |
| 10 | lines.push(style.center(style.dim('Terminal UI for Keet P2P Chat'), width)) | |
| 11 | lines.push('') | |
| 12 | ||
| 13 | if (model.error) { | |
| 14 | lines.push(style.center(style.red('Error: ' + model.error), width)) | |
| 15 | lines.push('') | |
| 16 | lines.push(style.center(style.dim('Make sure keet-daemon is running on port ' + (model.port || 3117)), width)) | |
| 17 | lines.push(style.center(style.dim('Press any key to retry, Ctrl+C to quit'), width)) | |
| 18 | } else { | |
| 19 | const dots = '.'.repeat((Math.floor(Date.now() / 500) % 3) + 1) | |
| 20 | lines.push(style.center(style.dim('Connecting to daemon' + dots), width)) | |
| 21 | } | |
| 22 | ||
| 23 | while (lines.length < height) lines.push('') | |
| 24 | return lines | |
| 25 | } |
| … | … | @@ -0,0 +1 @@ |
| 1 | /storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/node_modules |
| … | … | @@ -0,0 +1,22 @@ |
| 1 | { | |
| 2 | "name": "keet-tui", | |
| 3 | "version": "0.1.0", | |
| 4 | "description": "Terminal UI for Keet P2P chat — MVU architecture on bare-tty", | |
| 5 | "type": "module", | |
| 6 | "main": "index.js", | |
| 7 | "pear": { | |
| 8 | "name": "keet-tui", | |
| 9 | "type": "terminal" | |
| 10 | }, | |
| 11 | "imports": { | |
| 12 | "events": { | |
| 13 | "bare": "bare-events", | |
| 14 | "default": "events" | |
| 15 | }, | |
| 16 | "process": { | |
| 17 | "bare": "bare-process", | |
| 18 | "default": "process" | |
| 19 | } | |
| 20 | }, | |
| 21 | "dependencies": {} | |
| 22 | } |