OverviewFilesCommitsIssuesDocsReleasesPeers

keet-tui commits / e5865d3

Initial commit — keet-tui
Terminal UI for Keet P2P chat — MVU (model-view-update) architecture on bare-tty. Components: chat-view, input-box, member-list, room-list, status-bar. Views: main, splash, help. Drives the keet-daemon over UDS from a terminal-native interface. Extracted from hypercore-kb monorepo tracking on 2026-04-15. 16 source files, no node_modules ever tracked (correctly gitignored). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
e5865d3c0f1fb245299fd870a38c2df10168fd97
by plankatron <fleeky@gmail.com> · 2026-04-15T18:43:50.000Z

Changed files (18) +1399 -0

.gitignore +12 -0
……@@ -0,0 +1,12 @@
1# Dependencies
2node_modules/
3
4# Editor and OS cruft
5.DS_Store
6*.log
7*.swp
8.vscode/
9.idea/
10
11# Test artifacts
12coverage/
index.js +290 -0
……@@ -0,0 +1,290 @@
1import { Program } from './lib/program.js'
2import { KeetClient } from './lib/client.js'
3import { mainView } from './lib/views/main.js'
4import { splashView } from './lib/views/splash.js'
5import { helpView } from './lib/views/help.js'
6import { handleInputKey } from './lib/components/input-box.js'
7
8// --- Parse args ---
9const rawArgs = Pear.config.args || []
10let port = 3117
11for (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
19const client = new KeetClient({ port })
20
21// --- Model ---
22const 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 ---
208const titleCache = new Map()
209
210async 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 ---
228async 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 ---
285const program = new Program(model, {
286 pollInterval: 2000,
287 onPoll
288})
289
290program.run()
keet-bot.sh +90 -0
……@@ -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
6ROOM_ID="${1:-ar5jd5tc3od5hbxxg6818pw9ikpgeq39y8ybgonyu9n179cdz8po}"
7PORT=3117
8LOG="/tmp/keet-bot.log"
9INBOX="/tmp/keet-inbox"
10MY_MEMBER_ID="z9i4wpf3pmp3cwhh9nnwysbwxcgx1f8sminypcc63esbi8ih9iby"
11LAST_SEQ_FILE="/tmp/keet-bot-lastseq"
12POLL_INTERVAL=1
13
14log() {
15 local ts
16 ts="$(date '+%Y-%m-%d %H:%M:%S')"
17 echo "[$ts] $*" | tee -a "$LOG"
18}
19
20poll() {
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 "
30import json, sys
31try:
32 data = json.load(sys.stdin)
33 msgs = data.get('data', [])
34 if msgs: print(msgs[-1].get('seq', 0))
35 else: print(0)
36except: 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 "
65import json, sys
66try:
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}')
84except:
85 pass
86" "$result" "$saved_seq" "$MY_MEMBER_ID" 2>/dev/null)
87 done
88}
89
90poll
keet-watch.sh +54 -0
……@@ -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
6ROOM_ID="${1:-ar5jd5tc3od5hbxxg6818pw9ikpgeq39y8ybgonyu9n179cdz8po}"
7PORT=3117
8LOG="/tmp/keet-watch.log"
9
10log() {
11 local ts
12 ts="$(date '+%Y-%m-%d %H:%M:%S')"
13 echo "[$ts] $*" | tee -a "$LOG"
14}
15
16watch_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 "
31import json, sys
32try:
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}')
41except:
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
54watch_room
lib/client.js +92 -0
……@@ -0,0 +1,92 @@
1import http from 'bare-http1'
2
3export 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}
lib/components/chat-view.js +129 -0
……@@ -0,0 +1,129 @@
1import * as style from '../style.js'
2
3const 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
9export 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
70function 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
90function 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}
lib/components/input-box.js +82 -0
……@@ -0,0 +1,82 @@
1export 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}
lib/components/member-list.js +44 -0
……@@ -0,0 +1,44 @@
1import * as style from '../style.js'
2
3export 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}
lib/components/room-list.js +71 -0
……@@ -0,0 +1,71 @@
1import * as style from '../style.js'
2
3export 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
65function 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}
lib/components/status-bar.js +39 -0
……@@ -0,0 +1,39 @@
1import * as style from '../style.js'
2
3export 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}
lib/program.js +91 -0
……@@ -0,0 +1,91 @@
1import { Screen } from './screen.js'
2
3export 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}
lib/screen.js +104 -0
……@@ -0,0 +1,104 @@
1import tty from 'bare-tty'
2import ansi from 'bare-ansi-escapes'
3import KeyDecoder from 'bare-ansi-escapes/key-decoder'
4import { EventEmitter } from 'events'
5
6const ALT_SCREEN_ON = '\x1b[?1049h'
7const ALT_SCREEN_OFF = '\x1b[?1049l'
8
9export 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}
lib/style.js +133 -0
……@@ -0,0 +1,133 @@
1import ansi from 'bare-ansi-escapes'
2
3const { SGR } = ansi.constants
4
5// --- Text styling (wraps string with SGR codes) ---
6
7export function bold (s) { return ansi.modifierBold + s + ansi.modifierNormal }
8export function dim (s) { return ansi.modifierDim + s + ansi.modifierNormal }
9export function italic (s) { return ansi.modifierItalic + s + ansi.modifierNotItalic }
10export function underline (s) { return ansi.modifierUnderline + s + ansi.modifierNotUnderline }
11
12export function red (s) { return ansi.colorRed + s + ansi.colorDefault }
13export function green (s) { return ansi.colorGreen + s + ansi.colorDefault }
14export function yellow (s) { return ansi.colorYellow + s + ansi.colorDefault }
15export function blue (s) { return ansi.colorBlue + s + ansi.colorDefault }
16export function magenta (s) { return ansi.colorMagenta + s + ansi.colorDefault }
17export function cyan (s) { return ansi.colorCyan + s + ansi.colorDefault }
18export function white (s) { return ansi.colorWhite + s + ansi.colorDefault }
19export function brightBlack (s) { return ansi.colorBrightBlack + s + ansi.colorDefault }
20export function brightCyan (s) { return ansi.colorBrightCyan + s + ansi.colorDefault }
21export function brightGreen (s) { return ansi.colorBrightGreen + s + ansi.colorDefault }
22export function brightYellow (s) { return ansi.colorBrightYellow + s + ansi.colorDefault }
23
24export function bg256 (n, s) { return SGR('48;5;' + n) + s + SGR(49) }
25export function fg256 (n, s) { return SGR('38;5;' + n) + s + SGR(39) }
26
27export function invert (s) { return SGR(7) + s + SGR(27) }
28
29// --- String measurement (strips ANSI) ---
30
31const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g
32
33export function stripAnsi (s) {
34 return s.replace(ANSI_RE, '')
35}
36
37export function visibleLength (s) {
38 return stripAnsi(s).length
39}
40
41// --- Layout helpers ---
42
43export 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
69export function padRight (s, width) {
70 const vl = visibleLength(s)
71 if (vl >= width) return s
72 return s + ' '.repeat(width - vl)
73}
74
75export function padLeft (s, width) {
76 const vl = visibleLength(s)
77 if (vl >= width) return s
78 return ' '.repeat(width - vl) + s
79}
80
81export 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
91const 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
98export function hline (width, style = 'single') {
99 const ch = (BOX[style] || BOX.single).h
100 return ch.repeat(width)
101}
102
103export function vline (style = 'single') {
104 return (BOX[style] || BOX.single).v
105}
106
107export 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}
lib/views/help.js +71 -0
……@@ -0,0 +1,71 @@
1import * as style from '../style.js'
2
3const 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
26export 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}
lib/views/main.js +49 -0
……@@ -0,0 +1,49 @@
1import { roomListView } from '../components/room-list.js'
2import { chatView } from '../components/chat-view.js'
3import { memberListView } from '../components/member-list.js'
4import { statusBarView } from '../components/status-bar.js'
5import * as style from '../style.js'
6
7export 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}
lib/views/splash.js +25 -0
……@@ -0,0 +1,25 @@
1import * as style from '../style.js'
2
3export 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}
node_modules +1 -0
……@@ -0,0 +1 @@
1/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/node_modules
package.json +22 -0
……@@ -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}