import { Program } from './lib/program.js'
import { KeetClient } from './lib/client.js'
import { mainView } from './lib/views/main.js'
import { splashView } from './lib/views/splash.js'
import { helpView } from './lib/views/help.js'
import { handleInputKey } from './lib/components/input-box.js'
// --- Parse args ---
const rawArgs = Pear.config.args || []
let port = 3117
for (let i = 0; i < rawArgs.length; i++) {
if (rawArgs[i] === '--port' && rawArgs[i + 1]) {
port = parseInt(rawArgs[++i])
} else if (rawArgs[i].startsWith('--port=')) {
port = parseInt(rawArgs[i].split('=')[1])
}
}
const client = new KeetClient({ port })
// --- Model ---
const model = {
// State
screen: 'splash', // 'splash' | 'main'
connected: false,
error: null,
port,
// Identity
identity: null,
// Rooms
rooms: [],
selectedRoomIndex: 0,
activeRoomId: null,
// Chat
messages: [],
chatScroll: 0,
inputBuffer: '',
cursorPos: 0,
lastMessageTimestamp: 0,
// Members
members: [],
// Layout
focus: 'chat', // 'rooms' | 'chat' | 'members'
showHelp: false,
width: 80,
height: 24,
// View dispatch
view (w, h) {
if (this.screen === 'splash') return splashView(this, w, h)
if (this.showHelp) return helpView(this, w, h)
return mainView(this, w, h)
},
// MVU update
update (msg) {
switch (msg.type) {
case 'resize':
this.width = msg.width
this.height = msg.height
break
case 'connected':
this.screen = 'main'
this.connected = true
this.error = null
if (msg.identity) this.identity = msg.identity
if (msg.rooms) {
this.rooms = msg.rooms
if (this.rooms.length > 0 && !this.activeRoomId) {
this.activeRoomId = this.rooms[0].roomId
}
}
break
case 'connection-error':
this.connected = false
this.error = msg.error
break
case 'rooms-updated':
this.rooms = msg.rooms || []
// Keep selection in bounds
if (this.selectedRoomIndex >= this.rooms.length) {
this.selectedRoomIndex = Math.max(0, this.rooms.length - 1)
}
break
case 'messages-updated':
if (msg.messages && msg.messages.length > 0) {
const lastTs = msg.messages[msg.messages.length - 1].timestamp || 0
const hadNew = lastTs > this.lastMessageTimestamp
this.messages = msg.messages
if (hadNew && this.chatScroll === 0) {
this.lastMessageTimestamp = lastTs
}
}
break
case 'members-updated':
this.members = msg.members || []
break
case 'key':
return this._handleKey(msg.key)
}
return null
},
_handleKey (key) {
// Splash screen: any key retries connection
if (this.screen === 'splash') {
this.error = null
return null
}
// ? toggles help overlay
if (key.sequence === '?') {
this.showHelp = !this.showHelp
return null
}
// Any key dismisses help when shown
if (this.showHelp) {
this.showHelp = false
return null
}
// Tab: cycle focus
if (key.name === 'tab') {
if (key.shift) {
this.focus = this.focus === 'rooms' ? 'members'
: this.focus === 'chat' ? 'rooms' : 'chat'
} else {
this.focus = this.focus === 'rooms' ? 'chat'
: this.focus === 'chat' ? 'members' : 'rooms'
}
return null
}
// Focus-specific handling
if (this.focus === 'rooms') return this._handleRoomKey(key)
if (this.focus === 'chat') return this._handleChatKey(key)
return null
},
_handleRoomKey (key) {
if (key.name === 'up' || key.name === 'k') {
if (this.selectedRoomIndex > 0) this.selectedRoomIndex--
return null
}
if (key.name === 'down' || key.name === 'j') {
if (this.selectedRoomIndex < this.rooms.length - 1) this.selectedRoomIndex++
return null
}
if (key.name === 'return') {
const room = this.rooms[this.selectedRoomIndex]
if (room) {
this.activeRoomId = room.roomId
this.messages = []
this.chatScroll = 0
this.lastMessageTimestamp = 0
this.focus = 'chat'
}
return null
}
return null
},
_handleChatKey (key) {
// Page up/down for scrolling
if (key.name === 'pageup') {
this.chatScroll += 10
return null
}
if (key.name === 'pagedown') {
this.chatScroll = Math.max(0, this.chatScroll - 10)
return null
}
// Input handling
const result = handleInputKey(this, key)
if (result) {
if (result.send) {
const roomId = this.activeRoomId
const text = result.send
this.inputBuffer = result.inputBuffer
this.cursorPos = result.cursorPos
// Fire and forget send
if (roomId) {
client.send(roomId, text).catch(() => {})
}
return null
}
if (result.inputBuffer !== undefined) this.inputBuffer = result.inputBuffer
if (result.cursorPos !== undefined) this.cursorPos = result.cursorPos
}
return null
}
}
// --- Room title cache ---
const titleCache = new Map()
async function enrichRoomTitles (rooms) {
for (const room of rooms) {
if (room.title && room.title !== '(untitled)') continue
if (titleCache.has(room.roomId)) {
room.title = titleCache.get(room.roomId)
continue
}
try {
const config = await client.roomConfig(room.roomId)
if (config.ok && config.data && config.data.title) {
room.title = config.data.title
titleCache.set(room.roomId, config.data.title)
}
} catch {}
}
}
// --- Polling ---
async function onPoll (program) {
try {
if (!model.connected) {
const alive = await client.ping()
if (!alive) {
program.dispatch({ type: 'connection-error', error: 'Cannot reach daemon' })
return
}
const [identity, roomsResult] = await Promise.all([
client.identity(),
client.rooms()
])
const rooms = roomsResult.ok ? roomsResult.data : []
await enrichRoomTitles(rooms)
program.dispatch({
type: 'connected',
identity: identity.ok ? identity.data : null,
rooms
})
return
}
// Refresh rooms
const roomsResult = await client.rooms()
if (roomsResult.ok) {
await enrichRoomTitles(roomsResult.data)
program.dispatch({ type: 'rooms-updated', rooms: roomsResult.data })
}
// Refresh messages for active room
if (model.activeRoomId) {
const msgsResult = await client.messages(model.activeRoomId, 50)
if (msgsResult.ok) {
program.dispatch({ type: 'messages-updated', messages: msgsResult.data })
}
// Refresh members
const membersResult = await client.members(model.activeRoomId)
if (membersResult.ok) {
program.dispatch({ type: 'members-updated', members: membersResult.data })
}
}
} catch (err) {
if (model.connected) {
model.connected = false
model.screen = 'splash'
model.error = err.message || 'Connection lost'
} else {
program.dispatch({ type: 'connection-error', error: err.message || 'Connection failed' })
}
}
}
// --- Start ---
const program = new Program(model, {
pollInterval: 2000,
onPoll
})
program.run()