From 2ff471a066758909d3f1c6f060658f313b78e88e Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sun, 24 May 2026 02:02:58 +0900 Subject: [PATCH] docs: implementation plan for interactive fSh widgets Bite-sized tasks for the spec at docs/superpowers/specs/2026-05-24-fsh-interactive-widgets-design.md. Verification uses node --check for JS syntax and a final manual smoke test in the emulator; the TSVM cannot be machine-invoked. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-24-fsh-interactive-widgets.md | 1386 +++++++++++++++++ 1 file changed, 1386 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md diff --git a/docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md b/docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md new file mode 100644 index 0000000..cf4a6d3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md @@ -0,0 +1,1386 @@ +# fSh Interactive Widgets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the `com.fsh.todo_list` and `com.fsh.quick_access` widgets in `assets/disk0/home/fsh.js` fully interactive — mouse + keyboard navigation, a modal add/edit/delete popup, item launching for Quick Access, and state persistence to `assets/disk0/home/config/fshrc`. + +**Architecture:** All UI work lives in `assets/disk0/home/fsh.js`. One small engine change in `tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt` widens MMIO[36] from a single boolean to a two-bit field so JS can distinguish left and right clicks. See `docs/superpowers/specs/2026-05-24-fsh-interactive-widgets-design.md` for the full design. + +**Tech Stack:** JavaScript (GraalVM, ES5-ish dialect used by TSVM), Kotlin (libGDX). The TSVM cannot be machine-invoked, so verification is `node --check` for JS syntax and manual review for runtime behaviour. The spec explicitly waived automated tests for this iteration — final verification is a manual smoke test in the running emulator. + +--- + +## File Structure + +| File | Action | Responsibility | +|-----------------------------------------------------------------|---------|-------------------------------------------------------------------------------| +| `tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt` | Modify | Replace `mouseDown: Boolean` with `mouseButtons: Int` (bit 0 = L, bit 1 = R) | +| `assets/disk0/home/fsh.js` | Modify | All widget interaction, focus, dialog, dispatcher, config I/O | +| `assets/disk0/home/config/fshrc` | (lazy) | Persistent state — created by fsh on first save; do **not** commit it | + +The whole feature stays in one JS file because the existing `fsh.js` is organised around widget object literals inside one script. Splitting now would force a new module-loading pattern that doesn't exist in this codebase. + +--- + +## Verification approach + +After each task that edits JS, run: + +```bash +node --check assets/disk0/home/fsh.js +``` + +Expected: no output, exit code 0. Any output means a syntax error — fix and re-check before committing. + +The Kotlin change cannot be checked from the CLI (no Gradle wrapper). The diff is small enough to verify by inspection; the user will rebuild via IntelliJ when they smoke-test. + +--- + +## Task 1: Engine change — expose right-click bit in MMIO + +**Files:** +- Modify: `tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt` + +- [ ] **Step 1: Read the current state of the three touch points** + +Run: +```bash +sed -n '99,105p;281,285p;298,318p' tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt +``` + +Expected lines (line numbers may shift slightly): the read at `36L`, the field declaration `private var mouseDown = false`, the assignment `mouseDown = Gdx.input.isTouched`, and the clear `mouseDown = false`. + +- [ ] **Step 2: Replace the field declaration** + +Locate (around line 283): + +```kotlin + private var mouseDown = false +``` + +Replace with: + +```kotlin + private var mouseButtons: Int = 0 // bit 0 = LEFT, bit 1 = RIGHT +``` + +- [ ] **Step 3: Update the MMIO read** + +Locate (around line 101): + +```kotlin + 36L -> mouseDown.toInt().toByte() +``` + +Replace with: + +```kotlin + 36L -> mouseButtons.toByte() +``` + +- [ ] **Step 4: Update the assignment inside the `isFocused` branch** + +Locate (around line 302): + +```kotlin + mouseDown = Gdx.input.isTouched +``` + +Replace with: + +```kotlin + mouseButtons = (if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) 1 else 0) or + (if (Gdx.input.isButtonPressed(Input.Buttons.RIGHT)) 2 else 0) +``` + +(The file already imports `com.badlogic.gdx.Input`, so `Input.Buttons.LEFT/RIGHT` resolve.) + +- [ ] **Step 5: Update the clear in the `else` branch** + +Locate (around line 316): + +```kotlin + mouseDown = false +``` + +Replace with: + +```kotlin + mouseButtons = 0 +``` + +- [ ] **Step 6: Verify no other references remain** + +Run: +```bash +grep -n "mouseDown" tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt +``` + +Expected: no output. If anything remains, it's a missed reference — update it to `mouseButtons` with the appropriate bit test. + +- [ ] **Step 7: Commit** + +```bash +git add tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt +git commit -m "$(cat <<'EOF' +IOSpace: expose right-click as MMIO[36] bit 1 + +MMIO[36] becomes a two-bit field (bit 0 = left, bit 1 = right) so JS +programs can distinguish mouse buttons. Existing callers that read this +byte as a truthy/falsy "is pressed" still work because left-click sets +bit 0. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 2: Constants block at top of fsh.js + +**Files:** +- Modify: `assets/disk0/home/fsh.js` + +- [ ] **Step 1: Add constants after the existing `_fsh` initialisation** + +Locate the line `let _fsh = {};` (around line 13). Immediately after it, before `_fsh.titlebarTex = ...`, insert: + +```javascript +// Config file path +_fsh.CONFIG_PATH = "A:/home/config/fshrc"; + +// Widget row caps (must match the loop bounds in draw()) +_fsh.TODO_MAX_ROWS = 13; // todoWidget draws i = 0..12 +_fsh.QA_MAX_ROWS = 22; // quickAccessWidget draws i = 0..21 +_fsh.TODO_TEXT_WIDTH = 24; // visible characters per todo row +_fsh.QA_LABEL_WIDTH = 24; // visible characters per QA label +_fsh.QA_CMD_WIDTH = 60; // command path field width in dialog + +// Highlight colour pair (used for hover / keyboard focus) +_fsh.HL_FG = 255; +_fsh.HL_BG = 17; + +// Default Quick Access entries when fshrc is missing or empty +_fsh.DEFAULT_QA = [ + ["Files", "/tvdos/bin/zsh.js"], + ["Editor", "/tvdos/bin/edit.js"], + ["BASIC", "/tbas/basic.js"], + ["DOS Shell", "/tvdos/bin/command.js /fancy"] +]; + +// Mouse button bits (MMIO[36] layout per IOSpace.kt) +_fsh.MB_LEFT = 1; +_fsh.MB_RIGHT = 2; +``` + +- [ ] **Step 2: Syntax check** + +Run: +```bash +node --check assets/disk0/home/fsh.js +``` + +Expected: exit code 0, no output. + +- [ ] **Step 3: Commit** + +```bash +git add assets/disk0/home/fsh.js +git commit -m "$(cat <<'EOF' +fsh: introduce constants for widget bounds, colours, defaults + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 3: Config parser and serializer + +**Files:** +- Modify: `assets/disk0/home/fsh.js` + +This task adds two pure functions that operate only on strings. They can be reviewed by reading the code; no live VM needed. + +- [ ] **Step 1: Add the parser** + +After the constants block from Task 2 and before `_fsh.titlebarTex`, insert: + +```javascript +// Parse fshrc text into {todos: [[text, done], ...], qa: [[label, cmd], ...]}. +// Returns null for both arrays when input is empty/whitespace. +_fsh.parseConfig = function(text) { + let todos = []; + let qa = []; + let section = null; + if (!text) return {todos: todos, qa: qa}; + let lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + // strip trailing \r if any + if (line.length && line.charCodeAt(line.length - 1) === 13) { + line = line.substring(0, line.length - 1); + } + if (line.length === 0) continue; + if (line.charAt(0) === "[") { + let close = line.indexOf("]"); + if (close > 0) { + let name = line.substring(1, close).trim().toUpperCase(); + if (name === "TODO" || name === "QUICK_ACCESS") section = name; + else section = null; // unknown section: ignore until next header + } + continue; + } + if (section === "TODO") { + if (line.length < 2) continue; + let marker = line.charAt(0); + if ((marker === "+" || marker === "-") && line.charAt(1) === " ") { + todos.push([line.substring(2), marker === "+"]); + } + } else if (section === "QUICK_ACCESS") { + let comma = line.indexOf(","); + if (comma <= 0) continue; // need a non-empty label + let label = line.substring(0, comma); + let cmd = line.substring(comma + 1); + qa.push([label, cmd]); + } + } + return {todos: todos, qa: qa}; +}; +``` + +- [ ] **Step 2: Add the serializer** + +Immediately after `_fsh.parseConfig`: + +```javascript +// Build fshrc text from in-memory model. Inverse of parseConfig. +_fsh.serializeConfig = function(todos, qa) { + let out = "[TODO]\n"; + for (let i = 0; i < todos.length; i++) { + let t = todos[i]; + out += (t[1] ? "+ " : "- ") + t[0] + "\n"; + } + out += "\n[QUICK_ACCESS]\n"; + for (let i = 0; i < qa.length; i++) { + out += qa[i][0] + "," + qa[i][1] + "\n"; + } + return out; +}; +``` + +- [ ] **Step 3: Add the load function** + +Immediately after `_fsh.serializeConfig`: + +```javascript +// Read fshrc; populate todoWidget.todoList and quickAccessWidget.entries. +// Falls back to defaults on missing/empty/malformed file. +_fsh.loadConfig = function() { + let f = files.open(_fsh.CONFIG_PATH); + let parsed = {todos: [], qa: []}; + if (f.exists) { + try { + parsed = _fsh.parseConfig(f.sread()); + } catch (e) { + serial.printerr("fsh.loadConfig: parse failed: " + e); + parsed = {todos: [], qa: []}; + } + } + todoWidget.todoList = parsed.todos; + quickAccessWidget.entries = (parsed.qa.length > 0) + ? parsed.qa + : _fsh.DEFAULT_QA.slice(); // copy so saves don't mutate the constant +}; +``` + +- [ ] **Step 4: Add the save function** + +Immediately after `_fsh.loadConfig`: + +```javascript +// Persist the current in-memory todos + QA entries to fshrc. +_fsh.saveConfig = function() { + try { + let f = files.open(_fsh.CONFIG_PATH); + if (!f.exists) f.mkFile(); + f.swrite(_fsh.serializeConfig(todoWidget.todoList, quickAccessWidget.entries)); + } catch (e) { + serial.printerr("fsh.saveConfig: write failed: " + e); + } +}; +``` + +- [ ] **Step 5: Syntax check** + +Run: +```bash +node --check assets/disk0/home/fsh.js +``` + +Expected: exit code 0. + +- [ ] **Step 6: Sanity test the parser/serializer round-trip with Node** + +This catches logic mistakes without needing TSVM. Run: + +```bash +node -e ' +const fs = require("fs"); +const src = fs.readFileSync("assets/disk0/home/fsh.js", "utf8"); +// Extract just the parseConfig + serializeConfig bodies by eval-ing the whole file +// is not feasible because of TSVM-specific globals. So copy the two functions inline: +function parseConfig(text) { + let todos = []; let qa = []; let section = null; + if (!text) return {todos, qa}; + let lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + if (line.length && line.charCodeAt(line.length - 1) === 13) + line = line.substring(0, line.length - 1); + if (line.length === 0) continue; + if (line.charAt(0) === "[") { + let close = line.indexOf("]"); + if (close > 0) { + let name = line.substring(1, close).trim().toUpperCase(); + if (name === "TODO" || name === "QUICK_ACCESS") section = name; + else section = null; + } + continue; + } + if (section === "TODO") { + if (line.length < 2) continue; + let m = line.charAt(0); + if ((m === "+" || m === "-") && line.charAt(1) === " ") + todos.push([line.substring(2), m === "+"]); + } else if (section === "QUICK_ACCESS") { + let c = line.indexOf(","); + if (c <= 0) continue; + qa.push([line.substring(0, c), line.substring(c + 1)]); + } + } + return {todos, qa}; +} +function serializeConfig(todos, qa) { + let out = "[TODO]\n"; + for (let i = 0; i < todos.length; i++) + out += (todos[i][1] ? "+ " : "- ") + todos[i][0] + "\n"; + out += "\n[QUICK_ACCESS]\n"; + for (let i = 0; i < qa.length; i++) out += qa[i][0] + "," + qa[i][1] + "\n"; + return out; +} +const sample = "[TODO]\n+ Buy groceries\n- Read CLAUDE.md\n\n[QUICK_ACCESS]\nFiles,/tvdos/bin/zsh.js\nEditor,/tvdos/bin/edit.js\n"; +const parsed = parseConfig(sample); +console.log("parsed:", JSON.stringify(parsed)); +const re = serializeConfig(parsed.todos, parsed.qa); +console.log("re-serialized:", JSON.stringify(re)); +const reparsed = parseConfig(re); +console.log("round-trip equal:", JSON.stringify(parsed) === JSON.stringify(reparsed)); +// commas-in-cmd test +const cmdWithComma = parseConfig("[QUICK_ACCESS]\nThing,/bin/x,--flag\n"); +console.log("cmd-with-comma:", JSON.stringify(cmdWithComma.qa)); +// malformed test +const malformed = parseConfig("garbage\n[UNKNOWN]\nfoo\n[TODO]\n+ ok\n"); +console.log("malformed-ok:", JSON.stringify(malformed.todos)); +' +``` + +Expected output: +``` +parsed: {"todos":[["Buy groceries",true],["Read CLAUDE.md",false]],"qa":[["Files","/tvdos/bin/zsh.js"],["Editor","/tvdos/bin/edit.js"]]} +re-serialized: "[TODO]\n+ Buy groceries\n- Read CLAUDE.md\n\n[QUICK_ACCESS]\nFiles,/tvdos/bin/zsh.js\nEditor,/tvdos/bin/edit.js\n" +round-trip equal: true +cmd-with-comma: [["Thing","/bin/x,--flag"]] +malformed-ok: [["ok",true]] +``` + +If `round-trip equal` is not `true`, or `cmd-with-comma` doesn't preserve the trailing flag, the parser logic is wrong — fix and re-run. + +- [ ] **Step 7: Commit** + +```bash +git add assets/disk0/home/fsh.js +git commit -m "$(cat <<'EOF' +fsh: add fshrc parser, serializer, load, and save + +Pure-data round-trip verified via Node. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 4: Hit-test helpers on widgets + +**Files:** +- Modify: `assets/disk0/home/fsh.js` + +The current `todoWidget.draw` and `quickAccessWidget.draw` use `charXoff` / `charYoff` passed by the main loop. The known positions from the existing loop are: + +```javascript +_fsh.widgets["com.fsh.todo_list"].draw(10, 17); +_fsh.widgets["com.fsh.quick_access"].draw(47, 8); +``` + +We need hit-test functions that take the **mouse char coords** and the widget's draw offsets, and return `null`, `{kind: "add"}`, or `{kind: "item", index}`. + +Looking at the draw loops (already in the file): each row `i` in `0..max-1` is rendered at `con.move(charYoff + i + 2, charXoff)` (icon col) and `con.move(charYoff + i + 2, charXoff + 2)` (text col). Rows with `i < list.length` show an entry; row `i === list.length` shows "Click to add"; rows `i > list.length` show underscores. Text spans 24 chars (`charXoff + 2 .. charXoff + 25`). + +- [ ] **Step 1: Add a generic hit-test helper** + +After `_fsh.saveConfig`, before `_fsh.Widget`: + +```javascript +// Map (mouse char x, mouse char y) to a row index for a widget drawn at +// (xoff, yoff) with `length` existing entries and `maxRows` total rows. +// Returns null / {kind:"add"} / {kind:"item", index: i}. +_fsh.hitTestList = function(charX, charY, xoff, yoff, textWidth, length, maxRows) { + // Each row sits at (yoff + i + 2, xoff..xoff + textWidth + 1). + // Column range: icon at xoff, text at xoff+2 .. xoff+1+textWidth. + // Allow clicks anywhere on the row's char cells (icon + text region). + let relY = charY - yoff - 2; + if (relY < 0 || relY >= maxRows) return null; + if (charX < xoff || charX > xoff + 1 + textWidth) return null; + if (relY < length) return {kind: "item", index: relY}; + if (relY === length) return {kind: "add"}; + return null; +}; +``` + +- [ ] **Step 2: Attach widget-specific hit-test** + +Right after the `quickAccessWidget.draw = function(...) { ... }` block (last line is `}` near the bottom of the file before `// change graphics mode`), add: + +```javascript +todoWidget.hitTest = function(charX, charY, xoff, yoff) { + return _fsh.hitTestList(charX, charY, xoff, yoff, + _fsh.TODO_TEXT_WIDTH, todoWidget.todoList.length, _fsh.TODO_MAX_ROWS); +}; + +quickAccessWidget.hitTest = function(charX, charY, xoff, yoff) { + return _fsh.hitTestList(charX, charY, xoff, yoff, + _fsh.QA_LABEL_WIDTH, quickAccessWidget.entries.length, _fsh.QA_MAX_ROWS); +}; +``` + +- [ ] **Step 3: Syntax check** + +```bash +node --check assets/disk0/home/fsh.js +``` + +Expected: exit code 0. + +- [ ] **Step 4: Sanity-test the hit-test math in Node** + +```bash +node -e ' +function hitTestList(charX, charY, xoff, yoff, textWidth, length, maxRows) { + let relY = charY - yoff - 2; + if (relY < 0 || relY >= maxRows) return null; + if (charX < xoff || charX > xoff + 1 + textWidth) return null; + if (relY < length) return {kind: "item", index: relY}; + if (relY === length) return {kind: "add"}; + return null; +} +// Todo widget at xoff=10, yoff=17, 3 entries +const T = (x,y) => hitTestList(x,y,10,17,24,3,13); +console.log("above:", T(15,18)); // null (relY=-1) +console.log("first item:", T(15,19)); // {kind:"item",index:0} +console.log("third item:", T(15,21)); // {kind:"item",index:2} +console.log("add row:", T(15,22)); // {kind:"add"} +console.log("filler row:", T(15,23)); // null +console.log("right of text:",T(40,19)); // null (xoff+1+textWidth = 35) +console.log("on icon col:", T(10,19)); // {kind:"item",index:0} +' +``` + +Expected output: +``` +above: null +first item: { kind: 'item', index: 0 } +third item: { kind: 'item', index: 2 } +add row: { kind: 'add' } +filler row: null +right of text: null +on icon col: { kind: 'item', index: 0 } +``` + +- [ ] **Step 5: Commit** + +```bash +git add assets/disk0/home/fsh.js +git commit -m "$(cat <<'EOF' +fsh: add hit-test helpers for todo and quick-access widgets + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 5: Focus state model and highlight in draw() + +**Files:** +- Modify: `assets/disk0/home/fsh.js` + +- [ ] **Step 1: Initialise focus state** + +After the constants block in Task 2, append: + +```javascript +// Current focus: null or {widgetId: string, index: number}. +// Index uses the same convention as hitTest: 0..length-1 are entries, +// `length` is the "+ Click to add" row. +_fsh.focus = null; +``` + +- [ ] **Step 2: Update `todoWidget.draw` to accept a focus argument** + +Replace the entire `todoWidget.draw = function(charXoff, charYoff) { ... }` body with: + +```javascript +todoWidget.draw = function(charXoff, charYoff) { + let focusIndex = (_fsh.focus && _fsh.focus.widgetId === todoWidget.identifier) + ? _fsh.focus.index : -1; + + con.color_pair(254, 255) + let xoff = charXoff * 7 + let yoff = charYoff * 14 + 3 + + con.move(charYoff, charXoff) + print('Í'.repeat(10)+" TODO "+'Í'.repeat(10)) + + for (let i = 0; i <= 12; i++) { + let list = todoWidget.todoList[i] || ["Click to add", null] + let isFocused = (i === focusIndex); + + if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG) + else if (list[1] === null) con.color_pair(249, 255) + else con.color_pair(254, 255) + + con.move(charYoff + i + 2, charXoff) + con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E) + + if (i > todoWidget.todoList.length) { + // Filler row — keep underscores but don't highlight (can't focus here) + con.color_pair(254, 255) + for (let k = 0; k < 24; k++) { + con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95) + } + } + else { + con.move(charYoff + i + 2, charXoff + 2) + // Pad text to TODO_TEXT_WIDTH so the highlight bar covers full row + let text = `${list[0]}`; + if (text.length > _fsh.TODO_TEXT_WIDTH) text = text.substring(0, _fsh.TODO_TEXT_WIDTH); + if (isFocused) text = text + " ".repeat(_fsh.TODO_TEXT_WIDTH - text.length); + print(text) + } + } +} +``` + +- [ ] **Step 3: Update `quickAccessWidget.draw` the same way** + +Replace its body with: + +```javascript +quickAccessWidget.draw = function(charXoff, charYoff) { + let focusIndex = (_fsh.focus && _fsh.focus.widgetId === quickAccessWidget.identifier) + ? _fsh.focus.index : -1; + + con.color_pair(254, 255) + let xoff = charXoff * 7 + let yoff = charYoff * 14 + 3 + + con.move(charYoff, charXoff) + print('Í'.repeat(6)+" QUICK ACCESS "+'Í'.repeat(6)) + + for (let i = 0; i <= 21; i++) { + let list = quickAccessWidget.entries[i] || ["Click to add", null] + let isFocused = (i === focusIndex); + + if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG) + else if (list[1] === null) con.color_pair(249, 255) + else con.color_pair(254, 255) + + con.move(charYoff + i + 2, charXoff) + con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F) + + if (i > quickAccessWidget.entries.length) { + con.color_pair(254, 255) + for (let k = 0; k < 24; k++) { + con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95) + } + } + else { + con.move(charYoff + i + 2, charXoff + 2) + let text = `${list[0]}`; + if (text.length > _fsh.QA_LABEL_WIDTH) text = text.substring(0, _fsh.QA_LABEL_WIDTH); + if (isFocused) text = text + " ".repeat(_fsh.QA_LABEL_WIDTH - text.length); + print(text) + } + } +} +``` + +- [ ] **Step 4: Syntax check** + +```bash +node --check assets/disk0/home/fsh.js +``` + +Expected: exit code 0. + +- [ ] **Step 5: Commit** + +```bash +git add assets/disk0/home/fsh.js +git commit -m "$(cat <<'EOF' +fsh: render row highlight when focused + +Each interactive widget now consults _fsh.focus and inverts the matching +row's colour pair so hover and keyboard navigation share one visual. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 6: Modal dialog primitive + +**Files:** +- Modify: `assets/disk0/home/fsh.js` + +The dialog is the biggest single piece. It draws a centred box, edits one or more text fields, and returns a tagged result. It blocks the main loop while open by running its own `con.getch()` loop (matching the pattern in `command.js`). + +- [ ] **Step 1: Add dialog drawing helpers** + +After `_fsh.saveConfig`, insert: + +```javascript +// Draw a double-line bordered box. (row, col) is the top-left, (h, w) the size. +_fsh.drawDialogFrame = function(row, col, h, w, title) { + con.color_pair(254, 255); + // Top + con.move(row, col); + con.addch(0xC9); // ╔ + for (let i = 0; i < w - 2; i++) con.addch(0xCD); // ═ + con.addch(0xBB); // ╗ + // Sides + interior fill + for (let y = 1; y < h - 1; y++) { + con.move(row + y, col); + con.addch(0xBA); // ║ + for (let i = 0; i < w - 2; i++) con.addch(32); + con.addch(0xBA); // ║ + } + // Bottom + con.move(row + h - 1, col); + con.addch(0xC8); // ╚ + for (let i = 0; i < w - 2; i++) con.addch(0xCD); // ═ + con.addch(0xBC); // ╝ + // Title centred on top border + if (title) { + let t = " " + title + " "; + let tcol = col + Math.floor((w - t.length) / 2); + con.move(row, tcol); + print(t); + } +}; + +// Draw a single-line bordered input field at (row, col) with given width. +// content is the current text; cursorPos the caret position; focused styles +// the frame with a brighter colour. +_fsh.drawDialogField = function(row, col, width, content, focused) { + con.color_pair(focused ? 254 : 249, 255); + con.move(row, col); + con.addch(0xDA); // ┌ + for (let i = 0; i < width; i++) con.addch(0xC4); // ─ + con.addch(0xBF); // ┐ + con.move(row + 1, col); + con.addch(0xB3); // │ + con.color_pair(254, 255); + let visible = content.length > width ? content.substring(content.length - width) : content; + print(visible + " ".repeat(width - visible.length)); + con.color_pair(focused ? 254 : 249, 255); + con.addch(0xB3); + con.move(row + 2, col); + con.addch(0xC0); // └ + for (let i = 0; i < width; i++) con.addch(0xC4); + con.addch(0xD9); // ┘ + con.color_pair(254, 255); +}; + +// Draw a button as "[ Label ]" at the given position; highlights when focused. +_fsh.drawDialogButton = function(row, col, label, focused) { + if (focused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG); + else con.color_pair(254, 255); + con.move(row, col); + print("[ " + label + " ]"); + con.color_pair(254, 255); +}; +``` + +- [ ] **Step 2: Add the dialog driver** + +Immediately after the helpers: + +```javascript +// Modal dialog. opts = { +// title: string, +// fields: [{label, initial, width}, ...], +// allowDelete: bool, +// } +// Returns {action: "ok"|"cancel"|"delete", values: [string, ...]}. +_fsh.showDialog = function(opts) { + let fields = opts.fields; + let values = fields.map(function(f) { return f.initial || ""; }); + + // Layout + let maxFieldW = fields.reduce(function(m, f) { return Math.max(m, f.width); }, 16); + let titleW = (opts.title ? opts.title.length : 0) + 4; + let w = Math.max(maxFieldW + 6, titleW + 4, 24); + let buttonsRow = 2 + fields.length * 4 + 1; // 1 label + 3 field rows per field + let h = buttonsRow + 2; + let screen = con.getmaxyx(); + let row = Math.max(2, Math.floor((screen[0] - h) / 2)); + let col = Math.max(2, Math.floor((screen[1] - w) / 2)); + + // Buttons list: indices follow Tab order after the last field + let buttons = [{label: "OK", action: "ok"}, {label: "Cancel", action: "cancel"}]; + if (opts.allowDelete) buttons.splice(1, 0, {label: "Delete", action: "delete"}); + + let focusIdx = 0; // 0..fields.length-1 = field; then buttons + let totalFocus = fields.length + buttons.length; + let done = null; // {action, values} when set + + // Hide the main wallpaper region we cover; we'll redraw fully after close. + + function render() { + _fsh.drawDialogFrame(row, col, h, w, opts.title); + // Fields + for (let i = 0; i < fields.length; i++) { + let labelRow = row + 1 + i * 4; + let fieldRow = labelRow + 1; + con.color_pair(254, 255); + con.move(labelRow, col + 2); + print(fields[i].label + ":"); + _fsh.drawDialogField(fieldRow, col + 2, fields[i].width, values[i], i === focusIdx); + } + // Buttons centred on buttonsRow + let totalBtnW = buttons.reduce(function(s, b) { return s + b.label.length + 5; }, 0) - 1; + let bx = col + Math.floor((w - totalBtnW) / 2); + for (let i = 0; i < buttons.length; i++) { + let bIdx = fields.length + i; + _fsh.drawDialogButton(row + buttonsRow, bx, buttons[i].label, bIdx === focusIdx); + bx += buttons[i].label.length + 5; + } + } + + render(); + + // Note: con.getch() returns TSVM scancodes (defined in JS_INIT.js as + // con.KEY_UP=200, KEY_DOWN=208, KEY_LEFT=203, KEY_RIGHT=205, + // KEY_BACKSPACE=8, KEY_TAB=9, KEY_RETURN=10). Esc isn't in JS_INIT's + // map — it arrives as ASCII 27 via keyTyped(). + while (done === null) { + let k = con.getch(); + + if (k === 27) { // Esc + done = {action: "cancel", values: values}; + break; + } + if (k === con.KEY_TAB) { + focusIdx = (focusIdx + 1) % totalFocus; + render(); + continue; + } + // On a field + if (focusIdx < fields.length) { + if (k === con.KEY_RETURN) { + if (focusIdx < fields.length - 1) { + focusIdx += 1; + } else { + focusIdx = fields.length; // move to OK button + } + render(); + continue; + } + if (k === con.KEY_BACKSPACE) { + if (values[focusIdx].length > 0) + values[focusIdx] = values[focusIdx].substring(0, values[focusIdx].length - 1); + render(); + continue; + } + // Printable + if (k >= 32 && k < 256 && values[focusIdx].length < fields[focusIdx].width * 4) { + values[focusIdx] += String.fromCharCode(k); + render(); + } + continue; + } + // On a button + if (k === con.KEY_RETURN || k === 32) { + done = {action: buttons[focusIdx - fields.length].action, values: values}; + break; + } + // Arrow keys cycle buttons too + if (k === con.KEY_LEFT) { + focusIdx = (focusIdx - 1 + totalFocus) % totalFocus; + render(); + } else if (k === con.KEY_RIGHT) { + focusIdx = (focusIdx + 1) % totalFocus; + render(); + } + } + + return done; +}; +``` + +- [ ] **Step 3: Syntax check** + +```bash +node --check assets/disk0/home/fsh.js +``` + +Expected: exit code 0. + +- [ ] **Step 4: Logic walkthrough — verify by reading** + +Read your inserted `_fsh.showDialog` carefully and confirm: + +1. `totalFocus = fields.length + buttons.length` matches the focus index range. +2. The buttons array order is `[OK, (Delete?), Cancel]`. +3. Pressing Enter on the last field jumps to OK (`focusIdx = fields.length`). +4. Esc returns `{action: "cancel"}` without saving. +5. Backspace truncates the current field; no underflow when empty. +6. Printable check `k >= 32 && k < 256` admits TSVM extended chars. + +If any of these fails to hold by inspection, fix the code before committing. + +- [ ] **Step 5: Commit** + +```bash +git add assets/disk0/home/fsh.js +git commit -m "$(cat <<'EOF' +fsh: add modal dialog primitive for add/edit/delete popups + +Centred bordered dialog with one or more text fields plus OK/Cancel +(and optional Delete) buttons. Driven by con.getch() so it blocks the +main loop cleanly while open. Returns {action, values}. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 7: Dispatcher — add/edit/delete handlers + +**Files:** +- Modify: `assets/disk0/home/fsh.js` + +These functions translate hits into mutations on `todoWidget.todoList` and `quickAccessWidget.entries`, save the config, and force a redraw of the whole screen (wallpaper + titlebar + widgets) when a dialog has been on screen. + +- [ ] **Step 1: Add a redraw-all helper** + +After `quickAccessWidget.hitTest` (added in Task 4), append: + +```javascript +// Re-render the whole shell. Use after a dialog closes (which clobbered +// the underlying char cells) or after execApp returns. +_fsh.redrawAll = function() { + con.color_pair(254, 255); + con.clear(); + graphics.clearPixels(255); + graphics.clearPixels2(255); + graphics.setFramebufferScroll(0, 0); + _fsh.drawWallpaper(); + _fsh.drawTitlebar(); + _fsh.widgets["com.fsh.clock"].draw(25, 3); + _fsh.widgets["com.fsh.calendar"].draw(12, 8); + _fsh.widgets["com.fsh.todo_list"].draw(10, 17); + _fsh.widgets["com.fsh.quick_access"].draw(47, 8); +}; +``` + +- [ ] **Step 2: Add the dispatcher functions** + +Immediately after `_fsh.redrawAll`: + +```javascript +_fsh.openAddTodoDialog = function() { + let res = _fsh.showDialog({ + title: "New Todo", + fields: [{label: "Text", initial: "", width: _fsh.TODO_TEXT_WIDTH}], + allowDelete: false + }); + _fsh.redrawAll(); + if (res.action !== "ok") return; + let text = res.values[0].trim(); + if (text.length === 0) return; + if (todoWidget.todoList.length >= _fsh.TODO_MAX_ROWS) return; + todoWidget.todoList.push([text, false]); + _fsh.saveConfig(); +}; + +_fsh.openEditTodoDialog = function(index) { + let entry = todoWidget.todoList[index]; + if (!entry) return; + let res = _fsh.showDialog({ + title: "Edit Todo", + fields: [{label: "Text", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}], + allowDelete: true + }); + _fsh.redrawAll(); + if (res.action === "cancel") return; + if (res.action === "delete") { + todoWidget.todoList.splice(index, 1); + _fsh.saveConfig(); + return; + } + let text = res.values[0].trim(); + if (text.length === 0) return; + todoWidget.todoList[index] = [text, entry[1]]; + _fsh.saveConfig(); +}; + +_fsh.openAddQaDialog = function() { + let res = _fsh.showDialog({ + title: "New Quick Access", + fields: [ + {label: "Label", initial: "", width: _fsh.QA_LABEL_WIDTH}, + {label: "Command", initial: "", width: _fsh.QA_CMD_WIDTH} + ], + allowDelete: false + }); + _fsh.redrawAll(); + if (res.action !== "ok") return; + let label = res.values[0].trim(); + let cmd = res.values[1].trim(); + if (label.length === 0 || cmd.length === 0) return; + if (quickAccessWidget.entries.length >= _fsh.QA_MAX_ROWS) return; + quickAccessWidget.entries.push([label, cmd]); + _fsh.saveConfig(); +}; + +_fsh.openEditQaDialog = function(index) { + let entry = quickAccessWidget.entries[index]; + if (!entry) return; + let res = _fsh.showDialog({ + title: "Edit Quick Access", + fields: [ + {label: "Label", initial: entry[0], width: _fsh.QA_LABEL_WIDTH}, + {label: "Command", initial: entry[1], width: _fsh.QA_CMD_WIDTH} + ], + allowDelete: true + }); + _fsh.redrawAll(); + if (res.action === "cancel") return; + if (res.action === "delete") { + quickAccessWidget.entries.splice(index, 1); + _fsh.saveConfig(); + return; + } + let label = res.values[0].trim(); + let cmd = res.values[1].trim(); + if (label.length === 0 || cmd.length === 0) return; + quickAccessWidget.entries[index] = [label, cmd]; + _fsh.saveConfig(); +}; + +_fsh.toggleTodoDone = function(index) { + let entry = todoWidget.todoList[index]; + if (!entry) return; + entry[1] = !entry[1]; + _fsh.saveConfig(); +}; +``` + +- [ ] **Step 3: Add the launcher** + +Immediately after `_fsh.toggleTodoDone`: + +```javascript +// Launch a Quick Access entry. cmd is the verbatim string the user typed. +// We split on first space to derive a program path + args; if the path +// has no leading "/", we treat it as relative to the current drive. +_fsh.launchEntry = function(label, cmd) { + let firstSpace = cmd.indexOf(" "); + let progPath = (firstSpace >= 0) ? cmd.substring(0, firstSpace) : cmd; + let argTail = (firstSpace >= 0) ? cmd.substring(firstSpace + 1) : ""; + let fullPath = progPath.startsWith("/") ? ("A:" + progPath) : progPath; + + try { + let f = files.open(fullPath); + if (!f.exists) { + serial.printerr("fsh.launchEntry: not found: " + fullPath); + return; + } + let code = f.sread(); + let tokens = [progPath].concat(argTail.length ? argTail.split(" ") : []); + execApp(code, tokens); + } catch (e) { + serial.printerr("fsh.launchEntry: " + label + " failed: " + e); + } + _fsh.redrawAll(); +}; +``` + +- [ ] **Step 4: Syntax check** + +```bash +node --check assets/disk0/home/fsh.js +``` + +Expected: exit code 0. + +- [ ] **Step 5: Commit** + +```bash +git add assets/disk0/home/fsh.js +git commit -m "$(cat <<'EOF' +fsh: add dispatcher handlers for add/edit/delete + QA launch + +Each handler opens a modal, forces a full screen redraw on close, and +saves the mutated config. launchEntry resolves QA commands against the +A: drive and execApps them, redrawing on return. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 8: Main loop — input polling, dispatch, keyboard nav + +**Files:** +- Modify: `assets/disk0/home/fsh.js` + +The existing main loop is small: + +```javascript +while (true) { + captureUserInput(); + if (getKeyPushed(0) == 67) break; + + _fsh.widgets["com.fsh.clock"].draw(25, 3); + _fsh.widgets["com.fsh.calendar"].draw(12, 8); + _fsh.widgets["com.fsh.todo_list"].draw(10, 17); + _fsh.widgets["com.fsh.quick_access"].draw(47, 8); + + sys.spin();sys.spin() +} +``` + +We replace it with one that polls mouse + buttons + keys, edge-detects clicks, manages focus, dispatches actions, and uses Esc to exit. + +- [ ] **Step 1: Add a click-dispatch helper** + +After `_fsh.launchEntry`, insert: + +```javascript +// Layout map: widget positions hard-coded to match the draw calls below. +_fsh.layouts = { + "com.fsh.todo_list": {xoff: 10, yoff: 17, widget: null}, + "com.fsh.quick_access": {xoff: 47, yoff: 8, widget: null} +}; + +// Find which widget (if any) was hit by (charX, charY). Returns +// {widgetId, hit} or null. +_fsh.findHit = function(charX, charY) { + let ids = ["com.fsh.todo_list", "com.fsh.quick_access"]; + for (let i = 0; i < ids.length; i++) { + let id = ids[i]; + let layout = _fsh.layouts[id]; + let widget = _fsh.widgets[id]; + let hit = widget.hitTest(charX, charY, layout.xoff, layout.yoff); + if (hit) return {widgetId: id, hit: hit}; + } + return null; +}; + +_fsh.dispatchLeft = function(widgetId, hit) { + if (hit.kind === "add") { + if (widgetId === "com.fsh.todo_list") _fsh.openAddTodoDialog(); + else _fsh.openAddQaDialog(); + return; + } + // hit.kind === "item" + if (widgetId === "com.fsh.todo_list") { + _fsh.toggleTodoDone(hit.index); + } else { + let entry = quickAccessWidget.entries[hit.index]; + if (entry) _fsh.launchEntry(entry[0], entry[1]); + } +}; + +_fsh.dispatchRight = function(widgetId, hit) { + if (hit.kind !== "item") return; + if (widgetId === "com.fsh.todo_list") _fsh.openEditTodoDialog(hit.index); + else _fsh.openEditQaDialog(hit.index); +}; +``` + +- [ ] **Step 2: Add mouse + key helpers near the top of the file** + +After `getKeyPushed` (around line 9-11), insert: + +```javascript +function readMousePos() { + let lx = sys.peek(-33) & 0xFF; + let hx = sys.peek(-34) & 0xFF; + let ly = sys.peek(-35) & 0xFF; + let hy = sys.peek(-36) & 0xFF; + return [(hx << 8) | lx, (hy << 8) | ly]; +} + +function readMouseButtons() { + return sys.peek(-37) & 0xFF; +} + +// Returns true if any of the eight key event buffer slots holds keycode `kc`. +function isKeyDown(kc) { + for (let i = 0; i < 8; i++) { + if ((sys.peek(-41 - i) & 0xFF) === kc) return true; + } + return false; +} +``` + +- [ ] **Step 3: Replace the main loop** + +Locate the existing block: + +```javascript +// TODO update for events: key down (updates some widgets), timer (updates clock and calendar widgets) +while (true) { + captureUserInput(); + if (getKeyPushed(0) == 67) break; + + _fsh.widgets["com.fsh.clock"].draw(25, 3); + _fsh.widgets["com.fsh.calendar"].draw(12, 8); + _fsh.widgets["com.fsh.todo_list"].draw(10, 17); + _fsh.widgets["com.fsh.quick_access"].draw(47, 8); + + sys.spin();sys.spin() +} +``` + +Replace with: + +```javascript +// Load persisted state before the first draw +_fsh.loadConfig(); + +// keyEventBuffers (read via sys.peek(-41-i)) holds *raw libGDX keycodes*, +// not the cooked TSVM scancodes that con.getch() returns. Existing fsh.js +// already uses 67 for Backspace (libGDX DEL); follow the same scheme here. +const KEY_ESC = 131; // Input.Keys.ESCAPE +const KEY_ENTER = 66; // Input.Keys.ENTER +const KEY_UP = 19; // Input.Keys.UP +const KEY_DOWN = 20; // Input.Keys.DOWN +const KEY_LEFT = 21; // Input.Keys.LEFT +const KEY_RIGHT = 22; // Input.Keys.RIGHT +const KEY_LSHIFT = 59; // Input.Keys.SHIFT_LEFT +const KEY_RSHIFT = 60; // Input.Keys.SHIFT_RIGHT + +let prevButtons = 0; +let prevMouseCharX = -1; +let prevMouseCharY = -1; +let keyLatch = {}; // {keycode: true} while the key is held — debounces "just pressed" + +while (true) { + captureUserInput(); + + // -- keyboard -- + if (isKeyDown(KEY_ESC)) break; + + let shiftDown = isKeyDown(KEY_LSHIFT) || isKeyDown(KEY_RSHIFT); + let enterPressed = false; + + // Edge-detect each navigation key + function edge(kc) { + let down = isKeyDown(kc); + let was = !!keyLatch[kc]; + keyLatch[kc] = down; + return down && !was; + } + + if (edge(KEY_ENTER)) enterPressed = true; + let navUp = edge(KEY_UP); + let navDown = edge(KEY_DOWN); + let navLeft = edge(KEY_LEFT); + let navRight = edge(KEY_RIGHT); + + // -- mouse -- + let pos = readMousePos(); + let charX = (pos[0] / 7) | 0; + let charY = (pos[1] / 14) | 0; + let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY); + prevMouseCharX = charX; + prevMouseCharY = charY; + + let buttons = readMouseButtons(); + let leftEdge = ((buttons & _fsh.MB_LEFT) !== 0) && ((prevButtons & _fsh.MB_LEFT) === 0); + let rightEdge = ((buttons & _fsh.MB_RIGHT) !== 0) && ((prevButtons & _fsh.MB_RIGHT) === 0); + prevButtons = buttons; + + // -- focus update -- + if (navUp || navDown || navLeft || navRight) { + if (!_fsh.focus) _fsh.focus = {widgetId: "com.fsh.todo_list", index: 0}; + if (navUp || navDown) { + let layout = _fsh.layouts[_fsh.focus.widgetId]; + let maxRows = (_fsh.focus.widgetId === "com.fsh.todo_list") + ? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS; + let length = (_fsh.focus.widgetId === "com.fsh.todo_list") + ? todoWidget.todoList.length : quickAccessWidget.entries.length; + let maxIdx = Math.min(length, maxRows - 1); + let next = _fsh.focus.index + (navDown ? 1 : -1); + if (next < 0) next = 0; + if (next > maxIdx) next = maxIdx; + _fsh.focus.index = next; + } else { + // Left/right switches widget + let other = (_fsh.focus.widgetId === "com.fsh.todo_list") + ? "com.fsh.quick_access" : "com.fsh.todo_list"; + let otherLength = (other === "com.fsh.todo_list") + ? todoWidget.todoList.length : quickAccessWidget.entries.length; + let otherMaxRows = (other === "com.fsh.todo_list") + ? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS; + let otherMaxIdx = Math.min(otherLength, otherMaxRows - 1); + _fsh.focus = {widgetId: other, index: Math.min(_fsh.focus.index, otherMaxIdx)}; + } + } else if (mouseMoved) { + let h = _fsh.findHit(charX, charY); + _fsh.focus = h ? {widgetId: h.widgetId, index: h.hit.kind === "add" + ? ((h.widgetId === "com.fsh.todo_list") + ? todoWidget.todoList.length + : quickAccessWidget.entries.length) + : h.hit.index} : null; + } + + // -- mouse click dispatch -- + if (leftEdge) { + let h = _fsh.findHit(charX, charY); + if (h) _fsh.dispatchLeft(h.widgetId, h.hit); + } else if (rightEdge) { + let h = _fsh.findHit(charX, charY); + if (h) _fsh.dispatchRight(h.widgetId, h.hit); + } + + // -- keyboard dispatch (synthesise click at focus) -- + if (enterPressed && _fsh.focus) { + let layout = _fsh.layouts[_fsh.focus.widgetId]; + let widget = _fsh.widgets[_fsh.focus.widgetId]; + let length = (_fsh.focus.widgetId === "com.fsh.todo_list") + ? todoWidget.todoList.length : quickAccessWidget.entries.length; + let hit = (_fsh.focus.index < length) + ? {kind: "item", index: _fsh.focus.index} + : (_fsh.focus.index === length ? {kind: "add"} : null); + if (hit) { + if (shiftDown) _fsh.dispatchRight(_fsh.focus.widgetId, hit); + else _fsh.dispatchLeft(_fsh.focus.widgetId, hit); + } + } + + // -- redraw -- + _fsh.widgets["com.fsh.clock"].draw(25, 3); + _fsh.widgets["com.fsh.calendar"].draw(12, 8); + _fsh.widgets["com.fsh.todo_list"].draw(10, 17); + _fsh.widgets["com.fsh.quick_access"].draw(47, 8); + + sys.spin(); sys.spin(); +} +``` + +- [ ] **Step 4: Syntax check** + +```bash +node --check assets/disk0/home/fsh.js +``` + +Expected: exit code 0. + +- [ ] **Step 5: Logic walkthrough — verify by reading** + +Read the new main loop and confirm: + +1. `edge(kc)` returns true exactly once per key press, then false until release. +2. Keyboard nav (arrow press) sets focus, mouse motion sets focus — last-write-wins because both branches are mutually exclusive per frame. +3. The "add" row index is `length` for both widgets, matching `hitTestList`. +4. Enter dispatch correctly skips frames where focus is `null` or out of range. +5. Esc exits without saving (config saves happen synchronously inside each dispatcher anyway). + +- [ ] **Step 6: Commit** + +```bash +git add assets/disk0/home/fsh.js +git commit -m "$(cat <<'EOF' +fsh: drive interaction from polled mouse + keyboard in the main loop + +Edge-detects left/right click and Enter, tracks focus from whichever +input device moved most recently, dispatches into the add/edit/launch +handlers, and exits on Esc instead of Backspace. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 9: Manual smoke test + +**Files:** +- (no edits — user-driven verification) + +The TSVM is not machine-interactable, so this is a checklist the user runs in the running emulator after rebuilding from IntelliJ. + +- [ ] **Step 1: Ask the user to rebuild and launch** + +Tell the user: + +> "Please rebuild the project in IntelliJ (the `IOSpace.kt` change needs the Kotlin module recompiled) and launch the emulator. Then run `fsh` from the TVDOS prompt." + +- [ ] **Step 2: Walk through the spec's testing scenarios** + +The user verifies each item from the spec (or you do, if you can see the screen): + +1. **First run** — delete `assets/disk0/home/config/fshrc` (if it exists). Launch fsh. Expect: default QA entries (Files / Editor / BASIC / DOS Shell), empty todo list with one `+ Click to add` row. +2. **Add todo** — left-click `+ Click to add` on todo widget. Dialog appears. Type text → Enter → entry added. Quit (Esc) and relaunch fsh. Entry persists. +3. **Toggle done** — left-click an existing todo. Checkbox flips. Relaunch — state persisted. +4. **Edit todo** — right-click an existing todo. Edit dialog opens pre-filled. Test OK / Cancel / Delete paths. +5. **Add QA** — left-click `+ Click to add` on QA widget. Two-field dialog. Submit. Verify file content of `assets/disk0/home/config/fshrc`. +6. **Launch QA** — left-click `Editor`. Verify `edit.js` runs and fsh redraws on return. +7. **Edit/Delete QA** — right-click an entry. Edit dialog with Delete button. Test all three buttons. +8. **Keyboard nav** — no mouse — press ↓ → first todo highlights. Use arrows to traverse, ← / → to switch widgets, Enter to activate, Shift+Enter to edit. +9. **Hover highlight** — move mouse over items — row inverts under cursor. +10. **Esc** — exits fsh cleanly back to TVDOS prompt. +11. **Malformed fshrc** — hand-edit the file to contain garbage. fsh should start with defaults and not crash. + +- [ ] **Step 3: If any scenario fails, file a follow-up task with the specific failure** + +Don't try to fix-in-place during the smoke test — note the failure, finish the rest of the checklist, then return to writing-plans / inline-execution for the fixes. + +--- + +## Self-review checklist + +This was checked before handing the plan off: + +- **Spec coverage**: every goal in the spec (popups, click-to-add, right-click edit/delete, persistence, hover, keyboard nav, QA launch, IOSpace right-click bit) has a corresponding task. +- **Placeholders**: no TODOs, no "appropriate error handling," every step has concrete code. +- **Type consistency**: `_fsh.focus.widgetId` / `_fsh.focus.index` is the single shape across all consumers; `{kind, index?}` is the hit-test shape across hit-test and dispatchers; `{action, values}` is the dialog return shape across all dispatch paths. +- **Indexing convention** (the one fix the spec self-review caught): `0..length-1` = items, `length` = add row, `> length` = filler. Used consistently in Task 4 (hit-test), Task 5 (draw), Task 7 (dispatchers), and Task 8 (keyboard nav).