Skip to content

feat(tui): the whole app in a terminal with --tui (SPEC-15 phase 1) - #50

Merged
rducom merged 6 commits into
Evariops:mainfrom
qjoly:claude/lucid-brahmagupta-nznnc2
Oct 30, 2026
Merged

rducom merged 6 commits into
Evariops:mainfrom
qjoly:claude/lucid-brahmagupta-nznnc2

Conversation

@qjoly

@qjoly qjoly commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

SpaceMatters --tui [path] runs the app in a terminal. It covers:

  • the home menu: disks, any path, Low-Hanging Fruits, Podman machines, the container engine, kubectl contexts;
  • the live outline of a scan, with files listed per folder;
  • the queries behind the MCP tools: find, largest, cold;
  • the Low-Hanging Fruits pass;
  • deletion.

Same binary, no dependency: raw termios and VT sequences (SPEC-15).

Shape

  • The window's controllers, not a reimplementation. AppTUIHost drives ScanController and CleanupController, so a guard added for the window holds in the terminal too. Long operations run as one job at a time, named in the header. The list keeps drawing underneath.
  • Testable screens. The screens are pure values over TUIHost. Frames, key handling and confirmations are tested as values against a fake host.
  • Read-only tables. Kubernetes and containers show as tables: PVCs against live usage; images, volumes and containers by size.

Deleting

d moves the selection to the Trash and takes ⏎. D deletes it for good and only takes y. Around them:

  • Where it applies. Host scans only, never mid-scan, never the scan root, never while a job runs. Each refusal is stated in the footer.

  • What the question shows. The size, and the full path wrapped, not cut.

    • A folder the scanner never read says size unknown, never "0 B", and D refuses it.
    • After a stopped scan, sizes read at least.
  • Who can answer it. Only a key read after the question was drawn:

    • the rest of the read that opened it is dropped;
    • the input queue is flushed once the question is on screen;
    • while it is up, a read bringing more than one key is refused.

    Bracketed paste is on, and a paste only goes into text fields.

  • Journal. Every d/D is written to cleanup.jsonl (terminal-trash / terminal-delete); the cleanup pass journals itself, as in the window.

  • Leaving a scan. At the root, one "up" too many asks before discarding the scan.

The terminal

  • Restored on every exit. Every fatal signal restores the terminal, then the signal is re-raised with its default disposition. Ctrl-Z and fg work. A resize repaints. Losing the input exits non-zero.
  • Untrusted names. File names are sanitised and measured in cells. Autowrap is off. SPACEMATTERS_TUI_ASCII=1 handles terminals that draw ambiguous-width characters wide.
  • No redraw timer when idle. Once nothing moves, the loop waits for a key.

This branch also merges main: the cleanup screen shows #53's searching and empty rows, and lists each section once.

Testing

  • Unit tests. 306 in total, 62 of them in TUITests. SwiftLint strict is clean.
  • The real binary on a pty. Debug build, frames read back through a VT model (pyte); 34 checks, all passing:
    • Pastes and bursts. A bracketed Do you have a minute?⏎ is ignored. The same text sent unbracketed in one write opens the question without answering it. yy is refused, and a single y answers.
    • Deletion. d/D happen and are journalled; the Trash prompt shows the full path.
    • A chmod 000 folder. D is refused, and d says size unknown.
    • Files. Listed on the first visit to a folder.
    • The root. Leaving it asks first.
    • Idle cost. 0 % of a core over 10 s.
    • Signals. kill -INT restores the terminal (paste mode, wrap, cursor, screen) and the process dies of SIGINT.
    • Job control. Ctrl-Z then fg under zsh -i.
    • Arguments. --tui <file> exits 1.
    • Cleanup screen. Stand-ins read searching, empty rows read empty.
  • Not covered.
    • A visual pass in Terminal.app or iTerm2.
    • ssh.
    • Whose Full Disk Access applies to a shell-launched process: SPEC-15 §5 marks it unverified, and the UI points at the terminal's grant.

Known gaps (follow-ups)

  • e opens the selection without confirmation; over ssh, e/o/c act on the console session.
  • Invisible characters (zero-width, bidi) in names are dropped, not shown; two names differing only by one print alike.
  • The Full Disk Access hint shows for any refused folder, POSIX EACCES included.
  • The header's "N unreadable" count does not drop after a successful re-read; the folder's own flag does.
  • SPEC-15 §6: multi-select, verdict marks, --tui --vm/--k8s, OSC 52.

@qjoly
qjoly requested a review from rducom as a code owner October 18, 2026 13:30
@qjoly
qjoly marked this pull request as draft October 18, 2026 18:23

@rducom rducom left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Quentin! A terminal view is a great addition, and the shape is right: a pure TUIBrowser, Terminal for the I/O, read-only, no dependency. The name sanitising holds up too: folders named with ESC/OSC/BEL, C1 CSI and bidi overrides put nothing on the wire.

It can't go in yet though:

  1. CI is red because the test target doesn't compile, on Swift 6.2.4 (CI) and 6.4, so it isn't a toolchain thing. The failing target is the whole SpaceMattersTests, so merging this would switch off every test in the repo. With the 5 #expect(browser.apply(…)) lines fixed, all 15 tests pass.
  2. Several behaviours stated in the PR body and SPEC-15 don't hold once run. The terminal isn't restored on SIGINT or crash signals, the highlight slides during a live scan until the first key press, idle costs 1.2–5.7 % of a core, and unreadable folders show as (no files).
  3. SPEC-15 §5's live checklist hasn't been run yet (the spec says so). Please run it and paste the results here.

Inline comments are tagged by priority. Checklist:

Before merge

  • [blocking] Test target compiles (TUITests.swift:121-127)
  • [major] Restore the terminal on every fatal signal, then re-raise (Terminal.swift:37)
  • [major] Don't show unreadable folders as (no files); carry the MCP lower-bound / Full Disk Access caveat (TUIBrowser.swift:240)
  • [should fix] Selection pinned to a node from the first frame; keys resolved against the displayed frame (TUIBrowser.swift:88, :122)
  • [should fix] No 10 Hz rendering once the scan is done (TUI.swift:66)
  • Run §5 and align SPEC-15's claims with what's verified (SPEC-15-terminal-ui.md:4)

Should fix

  • ESC[K erasing the last column (Terminal.swift:77)
  • Job control: Ctrl-Z, SIGTSTP/SIGCONT (Terminal.swift:31)
  • A test for the loop and the terminal session (TUI.swift:16)

Minor / nits: inline; they can follow in a later PR. One more for the commit itself: its only author is Claude <noreply@anthropic.com> and it carries a claude.ai session link. Please amend with a human author (a Co-Authored-By trailer is fine).

How this was tested
  • The PR's unchanged TUI sources plus their non-UI dependencies (FSNode, DirectoryScanner, FSAttr, MountInfo, ScanBackend, ExtKey, Formatting) compiled in a scratch SwiftPM package, with a 3-line main.swift standing in for Entry's --tui branch. Debug and release (-Ounchecked).
  • A Python pty driver (a session leader, with the TUI as a foreground job like under a shell): byte capture, termios checks, keys and signals, CPU time from ps. Frames are read back through a minimal xterm-semantics VT model.
  • zsh -f -i for the INT / TSTP / fg scenarios.
  • Test tree: hostile names, a chmod 000 folder, 60 and 20,000 sub-folders, a regular file as root. Live scan of /System/Library (nothing TCC-protected, to avoid prompts).
  • Not covered: a visual check in Terminal.app or iTerm2, ssh, TCC/FDA on ~.

Comment thread Tests/SpaceMattersTests/TUITests.swift Outdated
Comment on lines +121 to +127
#expect(browser.apply(.char("x")))
#expect(browser.apply(.char("t")))
#expect(browser.showTypes)
#expect(browser.apply(.left))
#expect(!browser.showTypes)
#expect(!browser.apply(.char("q")))
#expect(!browser.apply(.interrupt))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Doesn't compile, on any toolchain, and takes the whole test target down with it.

apply is mutating, and #expect expands browser.apply(x) into __checkFunctionCall(browser.self, calling: { $0.apply($1) }, …) where $0 is immutable → cannot use mutating member on immutable value: '$0' is immutable. CI fails on these 5 lines with Swift 6.2.4, and Swift 6.4 fails the same way, so it isn't a toolchain gap: this file never compiled. Since the failing target is SpaceMattersTests, no test in the repo runs while this is in.

Ask: bind each result before asserting, e.g.

let keepsOpen = browser.apply(.char("x"))
#expect(keepsOpen)

With that applied to the 5 calls, all 15 tests pass locally.

Comment thread docs/specs/SPEC-15-terminal-ui.md Outdated
# SPEC-15 — Terminal UI: the outline, live, in a terminal

> **Request**: a TUI version of SpaceMatters, even a minimal one.
> **Status**: 🟡 **Phase 0 implemented** — `SpaceMatters --tui [path]`: [TUI](../../Sources/SpaceMatters/TUI/TUI.swift), [TUIBrowser](../../Sources/SpaceMatters/TUI/TUIBrowser.swift), [Terminal](../../Sources/SpaceMatters/TUI/Terminal.swift), 15 tests in [TUITests](../../Tests/SpaceMattersTests/TUITests.swift). Live driving on a real disk is still to do (§5).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] This status can't be true at merge time: the 15 tests have never compiled, and §5's live checklist hasn't been run.

Meanwhile the PR body states the restore, selection and terminal-gone behaviours as facts; some hold and some don't once run (see the other comments).

Ask: run §5 (Terminal.app + one other terminal, resize mid-scan, open a folder still marked …, quit with q, Ctrl-C and kill) and paste the results in the PR. Then align the spec with what's verified: §3.2 (idle cost, selection), §3.5 ("always given back"), §4.3 ("every size from 1×1 to 140×30"; the test samples 35 sizes).

Comment thread Sources/SpaceMatters/TUI/Terminal.swift Outdated
Comment on lines +37 to +38
signal(SIGTERM, restoreAndExit)
signal(SIGHUP, restoreAndExit)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] §3.5 "The terminal is always given back" doesn't hold: only SIGTERM and SIGHUP are handled.

Measured on a pty, PR sources, release build:

exit path terminal restored
q, Ctrl-C, Ctrl-D, kill -TERM, kill -HUP ✅
kill -INT ❌ raw, alternate screen, cursor hidden, autowrap off
kill -QUIT / -TRAP / -ABRT / -SEGV ❌
kill -TSTP ❌ raw while stopped

Under a real zsh -i, after kill -INT the prompt comes back on the alternate screen, with no cursor and no line wrapping, until a blind reset. The realistic trigger is a crash: this codebase deliberately traps rather than corrupts (unowned let parent), so any trap in TUI mode leaves the user's terminal broken.

Ask: restore on every fatal signal (INT, QUIT, ABRT, SEGV, BUS, TRAP, ILL, FPE), then re-raise with the default disposition (sigaction + SA_RESETHAND) so the exit status still says "killed by signal". Handle TSTP/CONT too (see line 31).

out.append(index == selected ? reverse(TextCell.padRight(text, width)) : text)
}
if rows.isEmpty {
out.append(dim(TextCell.fit(current.isScanned ? " (no files)" : " (scanning…)", width)))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] An unreadable folder is shown as empty: 0 B, then (no files).

A folder the scanner can't open is finished with zero files (DirectoryScanner.process, the fd < 0 branch), so its row reads 0 B 0.0% 0 — and opening it says 0 B on disk · 0 files / (no files). Tested with a chmod 000 folder holding 1 MiB. The only hint is the global "N unreadable, skipped" in the header.

The rest of the app already settled this: the MCP caveat says "All totals are lower bounds, and a folder reported as small may not be" and points to Full Disk Access, and the GUI has its FDA banner. It bites hardest on the use case the README sells, ssh: the TUI runs with the terminal's (or sshd's) TCC rights, not the app's (the open question of SPEC-14 §6 applies here as is). Without Full Disk Access, ~/Documents, ~/Desktop, ~/Downloads… will all read (no files). That's the "weaker promise" §0 rules out.

Ask: carry the MCP caveat when errorCount > 0 (lower bounds, plus the FullDiskAccess.isGranted hint), and don't assert (no files) for a folder that may not have been read. Even "(empty or unreadable)" is honest until FSNode records per-node read errors.

}

private func selectedIndex(in rows: [Row]) -> Int {
guard let selection else { return 0 }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[should fix] The selection only follows a node after the first key press.

selection is nil at start and after every open (line 148), and this returns 0, so the highlight is pinned to row 0: the "row 3" behaviour §3.2 says it avoids. Live scan of /System/Library, no key pressed: the highlighted folder changed 6 times in ~3.5 s (Input Methods → Filesystems → Fonts → Frameworks → AssetsV2 → PrivateFrameworks → AssetsV2). Once a key has set it, it does follow its folder. selectionFollowsItsFolderWhenRowsResort only covers that second case (it presses .end first).

Ask: pin the selection to a node on the first render and after open, and add a test for the initial state under a re-sort.

Comment thread README.md Outdated
/Applications/SpaceMatters.app/Contents/MacOS/SpaceMatters --tui ~
```

The same engine and the same live fill as the window, as a sortable outline: size, share, file count and last write per folder, arrow keys to walk in and out, `t` for file types. It is read-only — deleting stays in the app's cleanup pass — and draws with plain terminal sequences, no extra dependency.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] "as a sortable outline": it's sorted by size, not sortable.

private var footer: String {
showTypes
? "t/← back to folders q quit"
: "↑↓ move →/⏎ open ←/⌫ up t file types q quit"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The footer leaves out j/k/h/l, g/G and PgUp/PgDn, which §3.6 documents.

Comment thread Sources/SpaceMatters/TUI/Terminal.swift Outdated
guard tcgetattr(STDIN_FILENO, &savedTermios) == 0 else { return false }
var raw = savedTermios
raw.c_lflag &= ~tcflag_t(ECHO | ICANON | ISIG | IEXTEN)
raw.c_iflag &= ~tcflag_t(IXON | ICRNL)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] VMIN/VTIME are inherited rather than set (c_cc[VMIN] = 1, c_cc[VTIME] = 0); it only works because poll comes before every read.

if let idx = args.firstIndex(of: "--tui") {
// Same default as --mcp, for the same reason.
let next = idx + 1 < args.count ? args[idx + 1] : nil
let root = (next?.hasPrefix("-") == false ? next : nil) ?? NSHomeDirectory()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] --tui -some-dir silently scans $HOME, and extra arguments are ignored (same as --mcp). A usage error would avoid the surprise.

Comment thread Sources/SpaceMatters/TUI/Terminal.swift Outdated
// Alternate screen, cursor hidden, autowrap off — the last is a safety
// net: a line misjudged one cell too wide is clipped instead of
// wrapping and shearing every line below it.
output("\u{1B}[?1049h\u{1B}[?25l\u{1B}[?7l\u{1B}[2J")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The cursor is hidden and left wherever the last write ended, so Zoom/VoiceOver have nothing to follow. Parking it at the start of the selected row each frame is cheap (SPEC-08 only covers the GUI).

qjoly added 2 commits October 18, 2026 22:00
…SHA256:dgCRtjfNOnrnC9SyZsJ9Bs/LjcEAyWSS51cMZGDXxW8

feat(tui): browse a scan in a terminal with --tui (SPEC-15 phase 0)

`SpaceMatters --tui [path]` runs the same DirectoryScanner and shows the
directory outline live in a terminal: on-disk size, share, file count and
last write per folder, sorted by size and refreshed at the GUI's 10 Hz.
Arrow/vi keys to walk in and out, `t` for the exact scan-wide file types.

Read-only like the MCP server: deleting stays in the app's fenced,
journalled cleanup pass. No dependency: raw termios and VT sequences.

- TUIBrowser is pure (state + frames as [String]), so frames are tested
  as values; the selection follows a node, not an index, while rows
  re-sort under a live scan.
- File names are sanitised (no escape sequence reaches the terminal) and
  measured in cells (CJK/emoji/combining), autowrap off as a second fence.
- Terminal restored on q, Ctrl-C/D, SIGTERM and SIGHUP; stops if the
  terminal goes away instead of spinning.
…SHA256:dgCRtjfNOnrnC9SyZsJ9Bs/LjcEAyWSS51cMZGDXxW8

feat(tui): make --tui the whole app — menu, queries and deletion (SPEC-15 phase 1)

The terminal was an outline you could look at. It is now the app: the same
home menu the window's splash offers (disks, any path, Low-Hanging Fruits,
Podman machines, the container engine, every kubectl context), the outline
with files in it, the queries behind the MCP tools, and deletion.

It deletes through ScanController.remove and CleanupController.cleanSelected
— the very objects the window binds to — so it inherits every guard the GUI
has (host scans only, never mid-scan, one structural op at a time, epoch
checks, aggregates and the type table corrected, cleanup.jsonl journalled)
instead of growing a second implementation of tree surgery.

On top of that: `d` to the Trash, `D` for good and only on `y`, the
confirmation naming size and full path, refusals stated in the footer, and
quitting mid-job asking first.

- Screens are pure values over a TUIHost protocol (keys in, lines out), so
  the whole interface — deletion included — is tested against a fake host.
- The frame loop gets its own thread; the main thread keeps RunLoop.main for
  the main actor, the refresh timer and FSEvents. Long work runs as a named
  job with a spinner, so the list keeps filling while a folder is removed.
- Search hits and the "go there" walk resolve paths top-down: FSNode.parent
  is unowned, and climbing it from a detached subtree traps.
- Browser gains per-file rows, four sorts, info, re-read a folder, rescan,
  copy briefing, reveal/open; `/` find, `T` largest, `A` cold via TreeQuery.
- 41 TUI tests (273 overall), including every destructive path and frames
  that must fit from 1×1 to 140×30 with each overlay open.
@qjoly
qjoly force-pushed the claude/lucid-brahmagupta-nznnc2 branch from 6d1fe8d to 007c7f0 Compare October 19, 2026 06:37
qjoly added 2 commits October 19, 2026 09:07
… frame honesty

Every point of the review that still applied after the phase 1 rework.

Terminal:
- Handlers installed before raw mode, for every fatal signal (SIGINT, SIGQUIT,
  SIGPIPE and the crash signals, not just TERM/HUP). They restore, then
  re-raise with the default disposition: the process now dies of what killed
  it instead of exit(128+n). Ctrl-Z gives the terminal back cooked and
  suspends for real; SIGCONT re-enters raw mode and repaints.
- The saved termios and the session flag moved to raw pointers, cleared after
  the restore, with the handled signals blocked around enter/leave — a signal
  landing in a Swift accessor could trap on an exclusivity check.
- A cut escape sequence is carried to the next read instead of surfacing as
  Esc plus its letters, and ESC+key (Meta) no longer reads as a bare Esc, so
  Alt-q stops quitting. VMIN/VTIME are set rather than inherited.
- Writes retry on EAGAIN through poll(POLLOUT) and report failure, so a lost
  write repaints instead of leaving stale lines the diff thinks are fresh.
- No ESC[K after a full-width line (autowrap off leaves the cursor on the last
  column, and erase-to-end would wipe it), and the cursor is parked on the
  selected row for Zoom and screen readers.
- TERM=dumb or unset is refused with exit 2; losing the terminal is no longer
  a clean exit 0.

Model and browser:
- FSNode carries `isUnreadable`, set when open(2) refuses a directory: a
  folder the scanner was turned away from no longer reads as empty. The row
  says so, the empty screen explains Full Disk Access, and the header carries
  the MCP lower-bound caveat first, where an 80-column terminal keeps it.
- Row holds its metrics sampled once, so a row can no longer be ranked by one
  size and printed with another, and a share cannot exceed 100 %.
- The selection is pinned before the first frame (it used to slide under the
  user until the first key press) and keys resolve against the frame on
  screen, not a ranking recomputed between two of them.
- "1 file here", not "1 files"; `--tui -x` is a usage error, not a silent
  scan of $HOME.

Loop and chrome:
- 1 Hz once the scan is done and no job runs, 10 Hz only while something
  moves: 0.01 s of CPU over 8 s idle, measured, against 1.2-5.7 % of a core.
- SPACEMATTERS_TUI_ASCII=1 swaps the ambiguous-width chrome for ASCII,
  cell for cell, for terminals that draw ambiguous characters wide.

Tests (51 TUI, 283 overall): golden frames written by hand instead of
measuring the renderer with its own width function, every threshold width
(50/64/76 and their neighbours) with each overlay open, the decoder's carry,
the diff's ESC[K and cursor parking, the idle tick, and unreadable folders.
SPEC-15 §4 now reports the live checklist with its results.
The review noted the footer leaves out the vi aliases, g/G and the page
keys. That is the intent, not an omission: the footer is a starting legend
with one way to do each thing, and spelling out the shortcuts of someone who
already knows the browser costs every row its last column at 80 cells. `?`
and `m` carry the full map, and the footer points at both.
@qjoly
qjoly marked this pull request as ready for review October 19, 2026 10:37

@rducom rducom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround, Quentin! Most of round 1 is genuinely fixed, and it holds up on a pty against the real controllers: every fatal signal restores and re-raises (INT, TERM, QUIT, SEGV, TRAP, ABRT checked), no ESC[K after full-width lines, the selection is pinned and keys resolve against the frame on screen, idle is down to 0.1–0.6 % of a core, unreadable folders carry the lower-bound caveat, and CI is green with 51 TUI tests.

Phase 1 changes what this PR is, though: it now deletes. Two things come first:

  1. A paste or type-ahead can confirm a deletion the user never saw (inline on TUI.swift:119). A pasted Do you have a minute?⏎ permanently deleted the selected folder on the real binary; cd ~/foo⏎ sends it to the Trash.
  2. The PR title and body still describe phase 0, including "Read-only like the MCP server: deleting stays in the app's fenced, journalled cleanup pass". Please update them, and consider splitting: the read-only browser and menu could land now, with d/D and the cleanup pass in their own PR. Also, the first two commits now start with Good "git" signature for … with ED25519 key … (signature-check output in the subject); a reword would fix it before anything lands.

Round 1, status

  • ✅ test target compiles · signals restore and re-raise · selection pinned, keys on the displayed frame · idle tick · no ESC[K on full lines · unreadable folders flagged + caveat · lost writes repaint · decoder carry / Meta / UTF-8 · races in enter/leave · golden frames & thresholds · TERM check · exit statuses · plurals · sampled rows · --tui -x usage · VMIN/VTIME · cursor parking · types view scrolls
  • ⚠️ job control: kill -TSTP/fg works, Ctrl-Z itself doesn't · loop/termios tests: pure parts only (spec §6.5) · ambiguous chrome: opt-in ASCII mode
  • ❌ mixed locale · --tui <file> · sanitising that loses information · "exact"

Before merge

  • [blocking] Confirmations can't be answered by a paste or by keys read before the prompt was drawn (TUI.swift:119)
  • [major] No "0 B" in a prompt for a size that is unknown (TUIApp.swift:430)
  • [major] Journal d/D, or stop claiming it (README.md:66)
  • PR title/body updated (and ideally the destructive half split out); commit subjects reworded

Should fix

  • Files listed on the first entry into a folder (TUIBrowser.swift:211)
  • Ctrl-Z (Terminal.swift:281)
  • Compressed files keep their classification through delete (AppTUIHost.swift:184)
  • Don't discard a scan on one "up" key too many (TUIApp.swift:224)
How this was tested
  • The PR's sources at dc1b1f5, everything except the SwiftUI views (so TUI.run → AppTUIHost → ScanController/CleanupController exactly as shipped), release -Ounchecked, driven on a pty with the TUI as a foreground job; zsh -f -i for the Ctrl-Z/TSTP/fg cases.
  • Destructive checks only against throwaway folders, permanent deletion only (the Trash path through the PR's FakeHost, so no real Trash was touched).
  • 51/51 TUI tests pass locally too.

Comment thread Sources/SpaceMatters/TUI/TUI.swift Outdated
Comment on lines +119 to +121
for key in keys {
guard app.apply(key) else { return 0 }
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] A paste, or keys typed ahead, can answer a deletion prompt the user never saw.

Every key of one read is applied here in a row, with no frame drawn in between, and nothing filters what reaches the confirmation: y (or ⏎ for the Trash) is accepted from the same burst that opened it (TUIApp.swift:176-183). Bracketed paste isn't enabled (Terminal.swift:53 never sends ESC[?2004h), so a paste arrives as keystrokes.

Reproduced on the real binary, against a throwaway folder: with victim/ selected, a single write of Do you have a minute?⏎ (exactly what a terminal sends on paste) permanently deleted victim/. D opened the prompt, o and space were ignored, y confirmed. The confirmation was never drawn; the footer said Deleted — victim (3,00 MiB), and the rest of the paste kept going as commands (h at the root goes home). With the PR's own FakeHost:

burst (one read) result
cd ~/foo⏎ (a shell line pasted in the wrong tab) delete(big, permanently: false): moved to the Trash
Do you have a minute?⏎ delete(big, permanently: true)
Dy (typed ahead while the loop was busy) deleted

Ask: enable bracketed paste and drop pasted bytes everywhere but the text prompts; when a confirmation opens, discard the rest of the batch and flush pending input (tcflush(TCIFLUSH)), and only accept an answer read after the prompt was drawn. For D, asking to type the folder name (or yes) would also stop single-key accidents.

Comment thread Sources/SpaceMatters/TUI/TUIApp.swift Outdated
}

var lines = [
"\(Format.bytes(target.bytes)) on disk",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] The prompt states "0 B on disk" for a size that is unknown.

target.bytes is 0 for a folder that could not be opened (verified: Move locked to the Trash? 0 B on disk for a chmod 000 folder holding 1 MiB) and for any folder the scanner never reached after a cancelled scan (ScanController.remove only refuses phase == .scanning, so .cancelled partial trees are deletable). "Delete X permanently? 0 B on disk" reads as "it's empty".

Ask: say "size unknown: never read" when isUnreadable or !isScanned, and refuse D (or require an explicit extra step) for those.

Comment thread README.md Outdated

`/` finds folders by name across the scan ("19 `node_modules`, 6.7 GiB between them"), `T` ranks the largest, `A` the ones nobody has written to in six months — the questions the MCP tools answer, without an assistant. `c` copies the briefing. `m` opens the action menu, which doubles as the key legend, and `?` shows the rest.

**It deletes too.** `d` moves the selected row to the Trash, `D` deletes it for good, and the Low-Hanging Fruits screen empties the caches you tick — through the very same controllers, fences and `cleanup.jsonl` journal as the window, with a confirmation that names the size and the full path first. Nothing is removed mid-scan, nothing on a VM or SSH scan, and permanent deletion will not take `⏎` for an answer.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] d/D are not journalled. cleanup.jsonl is written by CleanupController only (the Low-Hanging Fruits pass). ScanController.remove(directory:)/remove(file:), which d and D go through (AppTUIHost.swift:172-190), don't journal anything, so a permanent delete from the terminal leaves no trail. SPEC-15 §2.4 and the 007c7f0 message make the same claim.

Ask: journal remove (a terminal delete deserves the trail more than anything), or correct the README/spec.

Comment on lines +211 to +217
mutating func refreshListing() {
let node = current
let key = ObjectIdentifier(node)
let count = node.directFileCount
if let listing, listing.node == key, listing.count == count { return }
listing = (key, count, filesProvider(node))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[should fix] Files never show the first time a folder is opened.

ScanController.filesIn answers [] on a cache miss and enumerates in the background (ScanController.swift:687-725); this caches that first empty answer keyed by the file count, and since the count doesn't change, it never asks again. Verified on the real binary: first entry into files/ shows only [5 files here] (still after 2 s), leaving and re-entering shows the 5 files. filesAreListedOneByOneWhenTheHostCanReadThem misses it because its provider answers synchronously.

Ask: re-ask while the listing is empty but directFileCount > 0 (or let the host signal that a listing landed), and test with a provider that answers [] first.

Comment on lines +281 to +282
default:
break // other control bytes: no binding uses them

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[should fix] Ctrl-Z does nothing, yet the README and SPEC-15 §3 say "Ctrl-Z and fg work".

ISIG is off (line 40), so the tty never turns 0x1A into SIGTSTP, and the decoder drops it here. Verified under zsh -i: Ctrl-Z leaves the TUI running, no job suspended. kill -TSTP then fg does work (alternate screen left, re-entered and repainted), which is what §4.2 actually tested.

Ask: map 0x1A to raise(SIGTSTP) (the handler already restores and suspends), plus a test that 0x1A decodes to a suspend.

Comment thread Sources/SpaceMatters/TUI/TUIApp.swift Outdated

var lines = [
"\(Format.bytes(target.bytes)) on disk",
TextCell.fitLeading(path, 56),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Not the full path that SPEC-15 §2.4 promises: fitLeading(path, 56) cuts the start (…4-fed0-…/deltest/locked). The info box wraps the whole path (TextCell.wrap); the prompt should too.

]

/// Applied to a finished frame line, so no screen has to know about it.
static func asciify(_ line: String) -> String {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This swaps characters in file names too (a — or · in a name becomes -/|), despite the comment saying names are left alone.

/// look" are the same empty screen and opposite facts: a folder reported
/// as empty because it was refused is how a user concludes there is
/// nothing to reclaim in the one place there is.
private var emptyReason: String {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Full Disk Access is offered for any open(2) refusal, but it only helps with TCC (EPERM): not for POSIX permissions (EACCES), a dead mount, or a file passed as the root.

if let written = node.newestWrite {
lines.append("newest write \(Format.age(written))")
} else {
lines.append("newest write unknown (streamed scan)")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] "(streamed scan)" is also shown for host-scanned folders with no files, or unreadable ones.

Comment thread docs/specs/README.md Outdated
| [SPEC-12](SPEC-12-auto-update.md) | Auto-updates (Sparkle 2) | J1.x, D4 | 1 d | SPEC-07 |
| [SPEC-13](SPEC-13-sunburst.md) | **Sunburst view** — polar projection of the world (same scan, no re-scan on switch) | feature request | 1–2 d | SPEC-09, SPEC-10 |
| [SPEC-14](SPEC-14-mcp-llm-analysis.md) | **LLM analysis** — token-budgeted digests, MCP server, verdicts painted back onto the map — shipped | feature request | 5–6 d (4 increments) | SPEC-09, SPEC-10, SPEC-13 (phase 3 only) |
| [SPEC-15](SPEC-15-terminal-ui.md) | **Terminal UI** — `--tui`: the live outline in a terminal, read-only, no dependency — phase 0 | feature request | ~1 d (phase 0) | — |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Still "read-only … phase 0" and "~1 d (phase 0)".

Brings in Evariops#53. The cleanup controller's rows gained a searching state and
empty rows that stay on screen without being selectable; the terminal's
cleanup screen mirrors both.
…urnal, job control

Blocking: a confirmation can no longer be answered by a paste or by keys
typed ahead. Bracketed paste is on, and a paste is text taken only by a text
field. A batch of keys stops at the one that opens a confirmation, the
input queue is flushed once the question is drawn, and while it is up a read
bringing more than one key is refused whole.

- A folder never read says "size unknown" instead of "0 B", and D refuses
  it. After a stopped scan, sizes read "at least".
- d and D are journalled in cleanup.jsonl (terminal-trash / terminal-delete).
- Ctrl-Z suspends; fg resumes and repaints.
- Files show on the first visit to a folder.
- Compressed files keep their classification through delete.
- One "up" too many at the root asks before discarding the scan.
- Jobs that cannot start say so, and tables stay loadable.
- The probes are skipped with --tui <path> until the menu is shown.
- --tui <file> exits 1.
- The unreadable flag clears on re-read.
- Whole-percent shares and localized seconds.
- No more "exact".
- Paths are wrapped in full.
- Cleanup sections are listed once.
- README and SPEC-15 match what was verified, including whose Full Disk
  Access applies to a shell-launched process.
@rducom rducom changed the title feat(tui): browse a scan in a terminal with --tui (SPEC-15 phase 0) feat(tui): the whole app in a terminal with --tui (SPEC-15 phase 1) Oct 30, 2026
@rducom

rducom commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Round 2 is addressed in e4516bb. It sits on top of a merge of main (b4c32b8), which needed one change: the cleanup screen now handles #53's searching and empty rows, and lists each section once.

Before merge

  • ✅ [blocking] Confirmation answered by a paste or by type-ahead. Four layers:

    1. Bracketed paste is on, and a paste is text that only a text field takes.
    2. A read stops at the key that opens a confirmation, and the rest is dropped.
    3. The input queue is flushed (TCIFLUSH) once the question is drawn.
    4. While the question is up, a read bringing more than one key is refused.

    Checked on the real binary: the bracketed Do you have a minute?⏎ is ignored. Sent unbracketed in one write, the same text opens Delete victim permanently? and nothing is deleted. yy is refused, and a lone y answers. Unit tests cover the three bursts from your table. The typed confirmation for D (folder name or yes) is not done.

  • ✅ [major] "0 B" for an unknown size. An unreadable or unreached folder reads Size unknown — this folder was never read, and D refuses it. After a stopped scan, sizes read at least.

  • ✅ [major] Journal. d/D are journalled by AppTUIHost (terminal-trash / terminal-delete, with path, bytes and outcome). README and SPEC-15 §2.4 now say who journals what.

  • ✅ Title and body updated to phase 1.

  • ⏭️ Commit subjects. Not reworded: that means force-pushing Quentin's branch, and the squash merge drops them anyway.

  • ⏭️ Split. The destructive half was not split out.

Should fix

  • ✅ Files on the first entry. An empty listing is re-asked while the folder has files. The test uses a provider that answers [] first.
  • ✅ Ctrl-Z. 0x1A decodes to a suspend, which raises SIGTSTP. Checked under zsh -i: the terminal comes back cooked, and fg repaints.
  • ✅ Compressed files. TUIFile.compressed is carried into remove(file:) and into the row's label.
  • ✅ One "up" too many at the root. It asks Close this scan? first: ⏎ closes, esc stays.

Minor

  • ✅ Jobs that can't start. They now say so, and tablesInFlight is undone. confirmClean checks busy.
  • ✅ Probes. Skipped with --tui <path> until the menu is first shown.
  • ✅ --tui <file>. Exits 1 with not a folder.
  • ✅ Unreadable flag. Cleared on re-read: invalidate resets it, and the scanner sets it again if the folder is still refused. The header's count still doesn't drop.
  • ✅ Mixed locale. Shares are whole percents (Format.share, now shared with TreeDigest), and seconds use the locale's separator.
  • ✅ "exact". Dropped.
  • ✅ Full path in the prompt. Wrapped in full by the new TextCell.wrapPath, also used by i.
  • ✅ TCC. SPEC-15 §5 marks it unverified, and the UI points at the terminal's Full Disk Access.
  • ✅ Nits. "(streamed scan)" is only shown for streamed scans, and docs/specs/README.md is updated.
  • ⏭️ e and ssh. No confirmation for e, and e/o/c over ssh still act on the console session.
  • ⏭️ Invisible characters. They are not shown visibly.
  • ⏭️ TUIText swapping characters in names.
  • ⏭️ Full Disk Access hint for EACCES.

Everything marked ⏭️ is listed as a follow-up in the PR body.

How this was tested
  • swift test: 306 tests, 62 of them in TUITests (11 new). SwiftLint --strict is clean.
  • The real binary on a pty, driven by a Python script. Frames are read back through pyte; 34 checks:
    • the bursts above;
    • d to the Trash and D for good, both journalled;
    • a chmod 000 folder;
    • a folder with 5 files opened for the first time;
    • leaving the root;
    • idle CPU over 10 s (0 %);
    • kill -INT: restore sequence written, death by SIGINT;
    • Ctrl-Z / fg / Ctrl-C under zsh -f -i;
    • --tui <file>;
    • the Low-Hanging Fruits screen, looked at only.
  • Deletions targeted throwaway folders only.
  • Not covered: a visual pass in Terminal.app or iTerm2, ssh, and TCC on a shell-launched process.

@rducom
rducom merged commit b7e0f10 into Evariops:main Oct 30, 2026
8 of 14 checks passed
@rducom rducom added the enhancement New feature or request label Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants