Files
tsvm/docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md
minjaesong 2ff471a066 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 <noreply@anthropic.com>
2026-05-24 02:02:58 +09:00

47 KiB

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:

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:

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):

    private var mouseDown = false

Replace with:

    private var mouseButtons: Int = 0  // bit 0 = LEFT, bit 1 = RIGHT
  • Step 3: Update the MMIO read

Locate (around line 101):

            36L -> mouseDown.toInt().toByte()

Replace with:

            36L -> mouseButtons.toByte()
  • Step 4: Update the assignment inside the isFocused branch

Locate (around line 302):

                mouseDown = Gdx.input.isTouched

Replace with:

                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):

                mouseDown = false

Replace with:

                mouseButtons = 0
  • Step 6: Verify no other references remain

Run:

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
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 <noreply@anthropic.com>
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:

// 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:

node --check assets/disk0/home/fsh.js

Expected: exit code 0, no output.

  • Step 3: Commit
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 <noreply@anthropic.com>
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:

// 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:

// 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:

// 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:

// 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:

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:

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
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 <noreply@anthropic.com>
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:

_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:

// 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:

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
node --check assets/disk0/home/fsh.js

Expected: exit code 0.

  • Step 4: Sanity-test the hit-test math in Node
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
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 <noreply@anthropic.com>
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:

// 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:

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:

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
node --check assets/disk0/home/fsh.js

Expected: exit code 0.

  • Step 5: Commit
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 <noreply@anthropic.com>
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:

// 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:

// 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
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
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 <noreply@anthropic.com>
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:

// 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:

_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:

// 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
node --check assets/disk0/home/fsh.js

Expected: exit code 0.

  • Step 5: Commit
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 <noreply@anthropic.com>
EOF
)"

Task 8: Main loop — input polling, dispatch, keyboard nav

Files:

  • Modify: assets/disk0/home/fsh.js

The existing main loop is small:

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:

// 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:

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:

// 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:

// 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
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
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 <noreply@anthropic.com>
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).