This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Readline-style shortcuts in the interactive filter input (closes #24)

The pty / pty i / pty interactive picker's filter input was append-
only — backspace + printable chars, no cursor, no word motion, no
kill-line. Editing longer queries was awkward compared to the shell
prompts people build muscle memory for.

Fixed at two layers:

1. applyTextKey (src/tui/widgets/form.ts) — the shared text-field
helper — gains ctrl+w (delete previous word) and ctrl+k (kill to
end of line). It already had backspace / delete / arrows / home
/ end / ctrl+a / ctrl+e / ctrl+u / alt+arrow and alt+b/f word
motion. So every consumer (interactive filter, form widget,
command palette, prompt bar) picks up the new shortcuts for free.

2. Interactive filter (src/tui/interactive.ts) — replaces the hand-
rolled append/slice key handling with a TextFieldState signal
routed through applyTextKey. The filter line renders via
renderFieldNodes so the cursor paints on top of the char under
it (inverse-video) without shoving neighbors sideways. Typing
resets the selection to 0 as before; pure cursor motion (home,
end, arrows) preserves the current selection.

Tests:
- widgets-form.test.ts: 8 new unit tests for ctrl+a, ctrl+e, ctrl+w
(end-of-line, mid-string, trailing-whitespace boundary, empty
field) and ctrl+k (mid-cursor, at-end).
- tui.test.ts: 2 new end-to-end tests through a real PTY confirming
ctrl+u clears the filter in one keystroke and ctrl+w removes the
trailing word, updating the rendered filter line.

Nathan Herald (Apr 24, 2026, 2:30 PM +0200) e9b208a9 260bb67b

+185 -23
+4
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Interactive TUI 6 + - **Readline-style editing shortcuts in the interactive filter input** (closes #24). The `pty` / `pty i` / `pty interactive` picker's filter input was append-only (backspace + printable chars) — no cursor, no word motion, no kill-line. It now carries a real cursor and routes every keystroke through `applyTextKey`, which means: `ctrl+a` / `ctrl+e` / `home` / `end` to jump, `left` / `right` / `alt+←/→` / `alt+b` / `alt+f` to walk and word-motion, `ctrl+u` to clear, `ctrl+w` to delete the previous word (new), `ctrl+k` to kill to end of line (new), and printable characters insert at the cursor. The filter line renders with `renderFieldNodes` so the cursor paints on top of the character under it (inverse-video) without shoving neighbors sideways. 7 + - **`applyTextKey` (in `@myobie/pty/tui`) gained `ctrl+w` and `ctrl+k`.** Every text-field consumer — the interactive filter, the form widget, the command palette, the prompt bar — inherits the shortcuts for free. 8 + 5 9 ### Fixes 6 10 - **Concurrent writers can no longer corrupt the session metadata file.** Reported against `pty tag` but the flaw spanned four write sites: `writeMetadata` (sessions.ts), supervisor state save, `pty supervisor reset`, and the event-log retention/truncation path. All four shared the same bug — `target + ".tmp"` is a single fixed path per target, so two concurrent writers truncate-and-overwrite each other's tmp file, and whichever `rename` runs second (or first, depending on timing) can land a half-written JSON blob into the target. Fix: new `atomicWriteFileSync` / `atomicWriteFile` helpers in `sessions.ts` that use a unique tmp path per writer (`<target>.tmp.<pid>.<rand>`), `writeFileSync` into that, then `rename` into place. POSIX same-filesystem `rename` is atomic; readers always see either the old file or the new one, never an intermediate. All four call sites now route through the helper. Event-log truncation (previously `writeFile` in place over a 1000-line rollover) also goes through tmp+rename, so `pty events` readers never see a mid-rewrite file. Contract: last-write-wins under concurrent writers — lost updates are possible, corruption is not. Large (> 4KB) `user.*` / `state.set` event payloads can still interleave at the POSIX `O_APPEND` level; the module documents this and recommends keeping large blobs in state rather than events. 7 11
+51 -23
src/tui/interactive.ts
··· 13 13 import { matchesAllTags, isReservedTagKey } from "../tags.ts"; 14 14 import { 15 15 app, screen, signal, computed, batch, 16 - text, panel, footer, 16 + text, panel, footer, row, 17 17 groupedSelectable, type SelectableGroup, 18 18 updateScrollRegion, themes, 19 + applyTextKey, renderFieldNodes, type TextFieldState, 19 20 type KeyEvent, type ScreenContext, type UINode, 20 21 } from "./index.ts"; 21 22 // Reuse utility functions from the existing screen modules ··· 50 51 // ============================================================ 51 52 52 53 const sessions = signal<SessionInfo[]>([]); 53 - const filterText = signal(""); 54 + // TextFieldState carries a cursor so ctrl+a/e/w/u/k + arrow keys + word 55 + // motion all work readline-style. The filter string itself is `.text`. 56 + const filterField = signal<TextFieldState>({ text: "", cursor: 0 }); 54 57 const selectedIndex = signal(0); 55 58 56 59 /** Tag filter from `--filter-tag key=value`. Filters the list AND auto-applies ··· 252 255 })) 253 256 : relayHosts.get(); 254 257 return buildFilteredGroups( 255 - filterText.get(), 258 + filterField.get().text, 256 259 sortedSessions.get(), 257 260 hosts, 258 261 ); ··· 352 355 viewport, 353 356 ); 354 357 355 - const filter = filterText.get(); 358 + const field = filterField.get(); 356 359 const tagFilter = filterTags.get(); 357 360 const tagFilterStr = Object.entries(tagFilter).map(([k, v]) => `#${k}=${v}`).join(" "); 358 - const filterLine = filter 359 - ? text(" Filter: " + filter + (tagFilterStr ? " " + tagFilterStr : ""), "primary") 361 + // renderFieldNodes returns [before, cursor, after] text nodes so the 362 + // cursor paints on top of the char under it rather than shoving 363 + // neighbors sideways. Always render with active=true; the input is 364 + // always focused in this screen. 365 + const [before, cursor, after] = renderFieldNodes(field.text, field.cursor, true); 366 + const filterLine = field.text 367 + ? row( 368 + text(" Filter: ", "primary"), 369 + before, cursor, after, 370 + ...(tagFilterStr ? [text(" " + tagFilterStr, "primary")] : []), 371 + ) 360 372 : tagFilterStr 361 - ? text(" Filter: " + tagFilterStr, "primary") 362 - : text(" Filter: (type to filter)", "muted", { dim: true }); 373 + ? row( 374 + text(" Filter: ", "primary"), 375 + before, cursor, after, 376 + text(" " + tagFilterStr, "primary"), 377 + ) 378 + : row( 379 + text(" Filter: ", "muted", { dim: true }), 380 + before, cursor, after, 381 + text(" (type to filter)", "muted", { dim: true }), 382 + ); 363 383 364 384 const hasRelay = relayHosts.get().length > 0; 365 385 ··· 433 453 } 434 454 } 435 455 if (key.name === "escape") { 436 - if (filterText.peek()) { 437 - batch(() => { filterText.set(""); selectedIndex.set(0); }); 456 + if (filterField.peek().text) { 457 + batch(() => { filterField.set({ text: "", cursor: 0 }); selectedIndex.set(0); }); 438 458 return true; 439 459 } 440 460 ctx.quit(); 441 461 return true; 442 462 } 443 - if (key.char === "q" && !key.ctrl && !key.alt && !filterText.peek()) { 463 + if (key.char === "q" && !key.ctrl && !key.alt && !filterField.peek().text) { 444 464 ctx.quit(); 445 465 return true; 446 466 } ··· 448 468 // here so the global default fires. If someone binds it in a context 449 469 // that should NOT quit (e.g. a composer with double-ctrl-c semantics), 450 470 // they intercept via AppConfig.onKey. 451 - if (key.name === "backspace") { 452 - if (filterText.peek().length > 0) { 453 - batch(() => { filterText.set(filterText.peek().slice(0, -1)); selectedIndex.set(0); }); 454 - } 455 - return true; 456 - } 457 - if (key.char && !key.ctrl && !key.alt) { 458 - batch(() => { filterText.set(filterText.peek() + key.char); selectedIndex.set(0); }); 471 + 472 + // Delegate edit keys to applyTextKey — gives us readline-style editing 473 + // (backspace, delete, left/right, home/end, alt+b/f for word motion, 474 + // ctrl+a/e for start/end, ctrl+u to clear-to-start, ctrl+w to delete- 475 + // prev-word, ctrl+k to kill-to-end, plus printable char insertion). 476 + // applyTextKey returns null when the key isn't a text-editing key, in 477 + // which case we fall through and swallow it (nothing else to handle). 478 + const prev = filterField.peek(); 479 + const updated = applyTextKey(prev, key); 480 + if (updated !== null) { 481 + batch(() => { 482 + filterField.set(updated); 483 + // Reset the selection whenever the filter TEXT changes. Pure cursor 484 + // movement (home, end, arrows) keeps the current selection. 485 + if (updated.text !== prev.text) selectedIndex.set(0); 486 + }); 459 487 return true; 460 488 } 461 489 return true; ··· 504 532 refreshRelayHosts(); 505 533 batch(() => { 506 534 sessions.set(updated); 507 - filterText.set(""); 535 + filterField.set({ text: "", cursor: 0 }); 508 536 const maxIdx = Math.max(0, totalItems.get() - 1); 509 537 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 510 538 }); ··· 538 566 refreshRelayHosts(); 539 567 batch(() => { 540 568 sessions.set(updated); 541 - filterText.set(""); 569 + filterField.set({ text: "", cursor: 0 }); 542 570 const maxIdx = Math.max(0, totalItems.get() - 1); 543 571 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 544 572 }); ··· 554 582 const updated = await listSessions(); 555 583 batch(() => { 556 584 sessions.set(updated); 557 - filterText.set(""); 585 + filterField.set({ text: "", cursor: 0 }); 558 586 const maxIdx = Math.max(0, totalItems.get() - 1); 559 587 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 560 588 }); ··· 566 594 const updated = await listSessions(); 567 595 batch(() => { 568 596 sessions.set(updated); 569 - filterText.set(""); 597 + filterField.set({ text: "", cursor: 0 }); 570 598 const maxIdx = Math.max(0, totalItems.get() - 1); 571 599 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); 572 600 });
+18
src/tui/widgets/form.ts
··· 88 88 if (key.name === "u" && key.ctrl) { 89 89 return { text: state.text.slice(state.cursor), cursor: 0 }; 90 90 } 91 + // ctrl+w: delete the word immediately behind the cursor. Matches 92 + // readline / shell behavior — users expect this to shrink the line 93 + // word-by-word from the left. 94 + if (key.name === "w" && key.ctrl) { 95 + const start = prevWordBoundary(state.text, state.cursor); 96 + return { 97 + text: state.text.slice(0, start) + state.text.slice(state.cursor), 98 + cursor: start, 99 + }; 100 + } 101 + // ctrl+k: kill to end of line from the cursor. In a single-line field 102 + // that's everything after the cursor. 103 + if (key.name === "k" && key.ctrl) { 104 + return { 105 + text: state.text.slice(0, state.cursor), 106 + cursor: state.cursor, 107 + }; 108 + } 91 109 // Printable character — ignore ctrl/alt-modified keys so shortcuts don't 92 110 // leak as text into the field. 93 111 if (key.char && !key.ctrl && !key.alt) {
+63
tests/tui.test.ts
··· 451 451 ); 452 452 453 453 it( 454 + "ctrl+u clears the filter in one keystroke", 455 + async () => { 456 + const sessionDir = makeSessionDir(); 457 + const name1 = uniqueName(); 458 + const name2 = uniqueName(); 459 + 460 + const bg1 = await createBackgroundSession(sessionDir, name1, "sh", ["-c", "sleep 300"], os.tmpdir()); 461 + bgPids.push(bg1.pid); 462 + const bg2 = await createBackgroundSession(sessionDir, name2, "sh", ["-c", "sleep 300"], os.tmpdir()); 463 + bgPids.push(bg2.pid); 464 + 465 + const tui = createTuiSession(sessionDir); 466 + await tui.waitForText(name1, 10000); 467 + await tui.waitForText(name2, 10000); 468 + 469 + // Type part of name1 to filter. Wait for name2 to disappear so we're 470 + // sure the filter has rendered before we measure the clear. 471 + tui.type(name1.slice(-4)); 472 + await tui.waitForAbsent(name2, 5000); 473 + 474 + // ctrl+u clears the whole filter. 475 + tui.press("ctrl+u"); 476 + await tui.waitForText(name2, 5000); 477 + 478 + const ss = tui.screenshot(); 479 + expect(ss.text).toContain(name1); 480 + expect(ss.text).toContain(name2); 481 + }, 482 + 20000, 483 + ); 484 + 485 + it( 486 + "ctrl+w deletes the previous word from the filter", 487 + async () => { 488 + const sessionDir = makeSessionDir(); 489 + const name1 = uniqueName(); 490 + 491 + const bg1 = await createBackgroundSession(sessionDir, name1, "sh", ["-c", "sleep 300"], os.tmpdir()); 492 + bgPids.push(bg1.pid); 493 + 494 + const tui = createTuiSession(sessionDir); 495 + await tui.waitForText(name1, 10000); 496 + 497 + // Type "foo bar" — two words. ctrl+w should remove "bar" and leave 498 + // "foo " in the filter line. The underlying fuzzy matcher probably 499 + // won't match a session name, so we check the rendered filter line 500 + // directly. 501 + tui.type("foo bar"); 502 + // Wait for both words to appear in the "Filter: ..." line. 503 + await tui.waitForText("foo bar", 5000); 504 + 505 + tui.press("ctrl+w"); 506 + // After ctrl+w, "bar" should be gone from the filter line; the filter 507 + // should still contain "foo ". 508 + await tui.waitForAbsent("foo bar", 5000); 509 + const ss = tui.screenshot(); 510 + expect(ss.text).toContain("Filter: foo"); 511 + expect(ss.text).not.toContain("foo bar"); 512 + }, 513 + 20000, 514 + ); 515 + 516 + it( 454 517 "typing filters the list, backspace unfilters", 455 518 async () => { 456 519 const sessionDir = makeSessionDir();
+49
tests/widgets-form.test.ts
··· 47 47 expect(applyTextKey(mid, k("u", { ctrl: true }))).toEqual({ text: "lo", cursor: 0 }); 48 48 }); 49 49 50 + it("ctrl+a moves the cursor to the start (same as home)", () => { 51 + expect(applyTextKey(hello, k("a", { char: "a", ctrl: true }))) 52 + .toEqual({ text: "hello", cursor: 0 }); 53 + }); 54 + 55 + it("ctrl+e moves the cursor to the end (same as end)", () => { 56 + expect(applyTextKey({ text: "hello", cursor: 1 }, k("e", { char: "e", ctrl: true }))) 57 + .toEqual({ text: "hello", cursor: 5 }); 58 + }); 59 + 60 + it("ctrl+w deletes the word immediately behind the cursor", () => { 61 + // "git status|" → "git |" 62 + const s: TextFieldState = { text: "git status", cursor: 10 }; 63 + expect(applyTextKey(s, k("w", { char: "w", ctrl: true }))) 64 + .toEqual({ text: "git ", cursor: 4 }); 65 + }); 66 + 67 + it("ctrl+w mid-string keeps everything after the cursor", () => { 68 + // "hello world|xxx" with cursor at 11 (end of 'world') → "hello |xxx" 69 + const s: TextFieldState = { text: "hello worldxxx", cursor: 11 }; 70 + const r = applyTextKey(s, k("w", { char: "w", ctrl: true })); 71 + expect(r).toEqual({ text: "hello xxx", cursor: 6 }); 72 + }); 73 + 74 + it("ctrl+w on whitespace immediately behind cursor: skips space then eats word", () => { 75 + // "foo bar|" (cursor at end, run of spaces behind bar) → "foo |" 76 + // prevWordBoundary skips non-word then word; here behind cursor is 'bar', 77 + // so it just eats 'bar'. 78 + const s: TextFieldState = { text: "foo bar", cursor: 9 }; 79 + const r = applyTextKey(s, k("w", { char: "w", ctrl: true })); 80 + expect(r).toEqual({ text: "foo ", cursor: 6 }); 81 + }); 82 + 83 + it("ctrl+w on empty field is a no-op", () => { 84 + expect(applyTextKey(empty, k("w", { char: "w", ctrl: true }))) 85 + .toEqual({ text: "", cursor: 0 }); 86 + }); 87 + 88 + it("ctrl+k kills to end of line from the cursor", () => { 89 + const mid: TextFieldState = { text: "hello world", cursor: 5 }; 90 + expect(applyTextKey(mid, k("k", { char: "k", ctrl: true }))) 91 + .toEqual({ text: "hello", cursor: 5 }); 92 + }); 93 + 94 + it("ctrl+k at end is a no-op", () => { 95 + expect(applyTextKey(hello, k("k", { char: "k", ctrl: true }))) 96 + .toEqual({ text: "hello", cursor: 5 }); 97 + }); 98 + 50 99 it("ignores ctrl/alt-modified printable keys so shortcuts don't leak", () => { 51 100 expect(applyTextKey(hello, k("s", { char: "s", ctrl: true }))).toBeNull(); 52 101 expect(applyTextKey(hello, k("q", { char: "q", alt: true }))).toBeNull();