OverviewFilesCommitsIssuesDocsReleasesPeers

p2pbuilders commits / 4ac995a

terminal: add compose mode for multi-line posts and replies
`submit` with no args now enters a multi-line editor: first line is the title, subsequent lines become the body preserving newlines. Type `.` on a blank line to submit, `:q` to cancel. Same pattern for `reply` inside a thread — multi-line compose if no args. One-liner forms (`submit "title" body` / `reply <body>`) still work for quick posts. Prompted by trying to paste a multi-paragraph Show HN into the terminal — each line parsed as a separate command; worse, pasting didn't produce a post at all. Compose mode fixes it.
4ac995a85fdbed15cd471873f566f202bd8863a8
by p2pbuilders <noreply@p2pbuilders.dev> · 2026-04-20T12:53:08.000Z

Changed files (1) +123 -8

src/terminal/main.js +123 -8
……@@ -151,7 +151,13 @@ async function main () {
151151 _display: [], // what's actually on screen: pinned + rest
152152 threadId: null, // opId hex when viewing a thread
153153 threadPost: null,
154 threadComments: []
154 threadComments: [],
155
156 // Compose mode — when set, stdin lines are collected into the body
157 // instead of being parsed as commands. Used for multi-line posts/replies.
158 compose: null // { kind: 'post'|'reply', step: 'title'|'body',
159 // title?: string, bodyLines: string[],
160 // parentOpId?: string }
155161 }
156162
157163 // ---- rendering ----
……@@ -205,7 +211,7 @@ async function main () {
205211 rest.forEach((p, i) => renderPostLine(p, pinned.length + i + 1, false))
206212 }
207213
208 process.stdout.write(`\n${ANSI.dim}commands: submit | open <n> | up/down <n> | delete <n> | sort <hot|new|top> | refresh | help | quit${ANSI.reset}\n`)
214 process.stdout.write(`\n${ANSI.dim}commands: submit · open <n> · up/down <n> · delete <n> · sort <hot|new|top> · r · help · quit${ANSI.reset}\n`)
209215 }
210216
211217 function renderThread () {
……@@ -256,7 +262,11 @@ async function main () {
256262 node.on('track', scheduleRedraw)
257263
258264 // ---- REPL (stdin line buffer, portable between Bare and Node) ----
259 const prompt = () => { process.stdout.write(`${ANSI.orange}›${ANSI.reset} `) }
265 const composePrompt = () => { process.stdout.write(`${ANSI.dim}… ${ANSI.reset}`) }
266 const prompt = () => {
267 if (state.compose) return composePrompt()
268 process.stdout.write(`${ANSI.orange}›${ANSI.reset} `)
269 }
260270 function printLine (s) { process.stdout.write(s + '\n'); prompt() }
261271
262272 let stdinBuf = ''
……@@ -269,7 +279,8 @@ async function main () {
269279 while ((nl = stdinBuf.indexOf('\n')) >= 0) {
270280 const line = stdinBuf.slice(0, nl).replace(/\r$/, '')
271281 stdinBuf = stdinBuf.slice(nl + 1)
272 handle(line)
282 if (state.compose) handleCompose(line)
283 else handle(line)
273284 }
274285 })
275286 const shutdown = async () => {
……@@ -303,6 +314,88 @@ async function main () {
303314
304315 const isUrl = (s) => /^https?:\/\//.test(s)
305316
317 // ---- compose mode: accumulates a multi-line body for submit/reply ----
318
319 function enterCompose (kind, { parentOpId = null } = {}) {
320 state.compose = {
321 kind, // 'post' or 'reply'
322 step: kind === 'post' ? 'title' : 'body',
323 title: '',
324 bodyLines: [],
325 parentOpId
326 }
327 process.stdout.write(
328 `\n${ANSI.orange}─── ${kind === 'post' ? 'new post' : 'new reply'} ───${ANSI.reset}\n` +
329 `${ANSI.dim}type ${ANSI.reset}.${ANSI.dim} on a blank line to submit · type ${ANSI.reset}:q${ANSI.dim} to cancel${ANSI.reset}\n`
330 )
331 if (kind === 'post') process.stdout.write(`${ANSI.dim}title:${ANSI.reset} `)
332 else process.stdout.write(`${ANSI.dim}write your reply…${ANSI.reset}\n`)
333 }
334
335 function exitCompose (msg) {
336 state.compose = null
337 if (msg) process.stdout.write(msg + '\n')
338 prompt()
339 }
340
341 async function handleCompose (line) {
342 const c = state.compose
343 if (!c) { prompt(); return }
344
345 const trimmed = line.trim()
346 if (trimmed === ':q' || trimmed === 'cancel') {
347 return exitCompose(`${ANSI.dim}cancelled.${ANSI.reset}`)
348 }
349
350 if (c.step === 'title') {
351 if (!trimmed) {
352 process.stdout.write(`${ANSI.red}title can't be empty · ${ANSI.reset}${ANSI.dim}title:${ANSI.reset} `)
353 return
354 }
355 c.title = trimmed
356 c.step = 'body'
357 process.stdout.write(`${ANSI.dim}body (multi-line OK):${ANSI.reset}\n`)
358 composePrompt()
359 return
360 }
361
362 // c.step === 'body'
363 if (line === '.') {
364 if (c.bodyLines.length === 0) {
365 process.stdout.write(`${ANSI.red}body is empty · write something or type ':q' to cancel${ANSI.reset}\n`)
366 composePrompt()
367 return
368 }
369 const body = c.bodyLines.join('\n').replace(/\s+$/, '')
370 process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`)
371 const t0 = Date.now()
372 try {
373 if (c.kind === 'post') {
374 // Detect if the whole body is just a URL (single line) — treat as link post.
375 const trim = body.trim()
376 const isSingleLineLink = !trim.includes('\n') && isUrl(trim)
377 const link = isSingleLineLink ? trim : null
378 const text = link ? '' : body
379 const { opId } = await rpc.dispatch('createPost', { title: c.title, body: text, link })
380 await refreshFeed()
381 process.stdout.write(`${ANSI.green}posted in ${Date.now() - t0}ms${ANSI.reset}\n`)
382 process.stdout.write(`${ANSI.dim}opId: ${ANSI.reset}${opId}\n`)
383 } else {
384 await rpc.dispatch('createComment', { parentOpId: c.parentOpId, body })
385 await refreshThread()
386 process.stdout.write(`${ANSI.green}replied in ${Date.now() - t0}ms${ANSI.reset}\n`)
387 }
388 } catch (err) {
389 process.stdout.write(`\n${ANSI.red}error: ${err.message}${ANSI.reset}\n`)
390 }
391 return exitCompose()
392 }
393
394 // Normal line — buffer it and stay in body mode.
395 c.bodyLines.push(line)
396 composePrompt()
397 }
398
306399 async function handle (line) {
307400 const parsed = parseCmd(line)
308401 if (!parsed) { prompt(); return }
……@@ -313,17 +406,24 @@ async function main () {
313406 case '?':
314407 printLine(`
315408 ${ANSI.bold}feed commands${ANSI.reset}
316 submit "title" <body-or-url> submit a post
409 submit new post (multi-line editor)
410 submit "title" <body> new post (one-liner)
317411 open <n> open thread for post #n
318412 up <n> down <n> vote on post #n
319413 sort <hot|new|top> change feed sort
320414 refresh r reload feed
321415
322416 ${ANSI.bold}thread commands${ANSI.reset}
323 reply <body> comment on current thread
417 reply reply (multi-line editor)
418 reply <body> reply (one-liner)
324419 up down vote on the post (in thread)
325420 back b back to feed
326421
422 ${ANSI.bold}compose editor${ANSI.reset}
423 <your text> any line joins the body
424 . submit (on its own line)
425 :q cancel
426
327427 ${ANSI.bold}moderation${ANSI.reset}
328428 delete <n> delete a post (author or admin)
329429 delete delete current thread (in thread)
……@@ -370,8 +470,14 @@ ${amAdmin ? '\n ' + ANSI.orange + '(admin) ' + ANSI.reset + 'you can delete any
370470 prompt()
371471 return
372472 case 'submit': {
473 // `submit` with no args → enter multi-line compose mode.
474 // `submit "title" body` → classic one-liner still works.
475 if (!rest.trim()) {
476 enterCompose('post')
477 return
478 }
373479 const { title, rest: body } = parseQuoted(rest)
374 if (!title) { printLine('usage: submit "title" <body-or-url>'); break }
480 if (!title) { printLine('usage: submit (multi-line) | submit "title" <body-or-url>'); break }
375481 const link = isUrl(body.trim()) ? body.trim() : null
376482 const text = link ? '' : body
377483 process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`)
……@@ -384,6 +490,11 @@ ${amAdmin ? '\n ' + ANSI.orange + '(admin) ' + ANSI.reset + 'you can delete any
384490 prompt()
385491 return
386492 }
493 case 'compose':
494 case 'c': {
495 enterCompose('post')
496 return
497 }
387498 case 'open': {
388499 const n = parseInt(rest, 10)
389500 if (!n || !state._display[n - 1]) { printLine(`no post #${n}`); break }
……@@ -465,7 +576,11 @@ ${amAdmin ? '\n ' + ANSI.orange + '(admin) ' + ANSI.reset + 'you can delete any
465576 }
466577 case 'reply': {
467578 if (!state.threadId) { printLine('no thread open. use: open <n>'); break }
468 if (!rest.trim()) { printLine('usage: reply <body>'); break }
579 // reply with no args → enter multi-line compose mode (as a reply)
580 if (!rest.trim()) {
581 enterCompose('reply', { parentOpId: state.threadId })
582 return
583 }
469584 process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`)
470585 const t0 = Date.now()
471586 await rpc.dispatch('createComment', { parentOpId: state.threadId, body: rest })