Skip to content

feat(tui): opt-in dev reload that restarts the TUI in place - #700

Merged
sirmalloc merged 12 commits into
sirmalloc:mainfrom
eric-engberg:feat/dev-reload
Oct 26, 2026
Merged

sirmalloc merged 12 commits into
sirmalloc:mainfrom
eric-engberg:feat/dev-reload

Conversation

@eric-engberg

@eric-engberg eric-engberg commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #669 (merged), which freed ctrl+r: before it, the line editor's plain r also fired on ctrl+r.

Why

To pick up a code change while working on the TUI, you had to quit and then redo any unsaved setup and navigation by hand. That's slow when you keep separate windows for plain and powerline setups.

Usage

CCSTATUSLINE_DEV_RELOAD=1 bun run start --config /tmp/ccstatusline-plain.json

The header shows dev reload: ctrl+r. Press ctrl+r to restart on the latest code exactly where you were. The --config file is optional; it just keeps each window's saved setup separate from your real config.

How it works

  • Supervisor. With the variable set, bun run start becomes a small supervisor. It relaunches the same command (runtime, script and original arguments) as a child process, and the child runs the TUI.
  • ctrl+r writes a snapshot to a file in a private (0700) temp directory that each supervisor run creates for itself, so a TUI never restores a snapshot left by an earlier run or planted by another user, and exits with code 75 (EX_TEMPFAIL). The supervisor then starts a fresh process, which resumes from the snapshot. The snapshot holds:
    • the unsaved settings
    • the original settings, so the unsaved-changes state and ctrl+s behave as before
    • the current screen, selected line, menu positions, and line editor cursor
  • Restoring screens. Screens that only need that data restore in place: main, the line selector and editor, colors, terminal options, global overrides, and powerline. Screens with flow state of their own (confirm dialogs, install and update, import and export) resume on the main menu. An open sub-editor isn't restored; you land on its widget in the line editor.
  • Validation. Settings in the snapshot are re-validated against the schema, because the reload may have changed it. A corrupt or invalid snapshot means a normal start.
  • Broken code. If the new code fails to start, which happens when you reload mid-edit, the supervisor prints Reload failed (exit N). Fix the code, then press Enter to retry, or q to quit. and keeps the snapshot.
  • Exit. A normal exit (ctrl+c or the Exit menu) ends the supervisor and removes the snapshot directory. If the supervisor itself is sent SIGTERM, SIGINT or SIGHUP (kill, or closing the terminal), it passes the signal on to the TUI, waits for it, removes the directory and exits with 128 + the signal, so no TUI is left running on its own.

Without the variable nothing changes: no supervisor, no key, and no header note. The module is only loaded through a dynamic import in the interactive branch. The status-line render path loads the same code as before, about 0.7 KB more for the environment check in the entry.

ItemsEditor gains optional initialSelectedIndex and onSelectedIndexChange props, so the cursor survives a reload. Entering a line still starts at the top, as before.

Update: Drop the snapshot once the settings file is written

  • Retrying after a crash no longer brings back settings from before a save. The supervisor hands every TUI it starts the same snapshot path, including the one it relaunches after "Reload failed". Until now only ctrl+r wrote that snapshot, and nothing ever removed it. So with ctrl+r, then a save, then a crash, the retry restored the pre-save settings along with the pre-save original. Deleted widgets came back, Save & Exit was offered for changes that didn't exist, and the next save wrote the older settings over the newer file. Now App deletes the snapshot through a new discardDevReloadSnapshot in src/utils/dev-reload.ts whenever originalSettings changes after the first load. After that load, it only changes once the settings file has been written: ctrl+s, Save & Exit, or an install, update or uninstall recording itself. One effect covers all of those writes without touching each save path, so the change stays inside the dev reload code. A retry after a write loads the file. A crash before any write, which is the case the retry exists for, still resumes from the snapshot on the same screen.

Demo

Update

Dev reload (CCSTATUSLINE_DEV_RELOAD=1) on the demo's default four-widget line, with sample data only. The flow: delete Git Changes, ctrl+r, delete Git Branch, ctrl+s, then the TUI is killed to stand in for a crash and Enter at "Reload failed" retries. Before, the retry reopens on Edit Line 1 with Git Branch back, and the main menu offers Save & Exit for changes that were already saved. After, it opens the saved line (Model, Context Length) on the main menu with nothing unsaved.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Testing

  • Unit tests:
    • mode detection from the environment
    • the supervisor loop with a fake launcher: reload relaunches, a normal exit stops and cleans up, a crash prompts with retry or quit
    • snapshot round-trip, unrestorable and unknown screens going to main, and missing, corrupt or invalid snapshots giving a fresh start
  • An Ink test for the line editor's cursor props.
  • bun test: 3075 pass. bun run lint: clean.
  • Manual run in tmux, end to end:
    • an unsaved change on the line editor's first widget plus ctrl+r gives the same screen, cursor and change, and the main menu still offers "Save & Exit"
    • editing the Model widget's preview text plus ctrl+r makes the new text show up
    • a syntax error plus ctrl+r shows Bun's error and the retry prompt; fixing it and pressing Enter goes back in place
    • ctrl+c exits the supervisor with 0 and removes the snapshot
    • SIGTERM to the supervisor (Bun, and the built dist/ under Node) exits with 143 and leaves no TUI process or snapshot directory; the same at the retry prompt; closing the tmux session (SIGHUP) leaves nothing behind
  • Tests for the private snapshot directory (a file planted at the old fixed path isn't restored; the directory is 0700, new per run, and removed) and for passing SIGTERM, SIGINT and SIGHUP on to the TUI
    • without the variable, there's no header note and ctrl+r does nothing
  • On today's main: the built CLI's plain and Powerline renders match main under Bun 1.4.2 and Node 26.10.0, and ctrl+r driven in tmux under both replaces the TUI process and resumes on the same line editor widget.

Update:

  • New test in src/tui/__tests__/App-dev-reload.test.ts: 'keeps the snapshot for a crash after the reload, but drops it once the settings are saved'. It starts from a snapshot, checks the snapshot survives the load, presses ctrl+s and checks it is gone. Without the fix it fails at that last step. With the fix deliberately changed to drop the snapshot on the first load, both this test's first assertion and the existing resume test fail. The file's beforeEach now also points CLAUDE_CONFIG_DIR at the temp dir, because a save syncs widget hooks into Claude Code's settings.
  • bun test (scratch HOME and CLAUDE_CONFIG_DIR): 3189 pass, 0 fail, 9144 expect() calls across 180 files.
  • bun run lint: exit 0 (tsc --noEmit and ESLint with --max-warnings=0).
  • Node v26.10.0, node node_modules/vitest/vitest.mjs run src/tui/__tests__/App-dev-reload.test.ts src/utils/__tests__/dev-reload.test.ts: 2 files, 20 tests passed.
  • Manual run of the real supervisor in a PTY under Bun, with scratch HOME, CLAUDE_CONFIG_DIR and TMPDIR. Steps: ctrl+r, a save, then SIGKILL of the TUI, then Enter at the retry prompt. The TUI reopened on the saved file with no stale widget and no Save & Exit, and the file on disk was unchanged. With the same steps but no save, the TUI still resumed on Edit Line 1 with the unsaved edit.
  • Demo recorded with VHS from this branch and from the previous tip, in both Powerline and plain mode.

eric-engberg and others added 12 commits October 9, 2026 13:00
Picking up a code change meant quitting the TUI and redoing any unsaved
setup and navigation by hand.

With CCSTATUSLINE_DEV_RELOAD=1 the interactive TUI runs under a small
supervisor process. ctrl+r writes a snapshot (unsaved and original
settings, current screen, selected line, menu positions, line editor
cursor) and exits with code 75; the supervisor starts a fresh process on
the latest code, which resumes from the snapshot. Screens that carry
their own flow state (confirm dialogs, install/update, import/export)
resume on the main menu. If the new code fails to start, the supervisor
keeps the snapshot and offers Enter to retry once it's fixed, or q to
quit.

Without the variable nothing changes: no supervisor, no key, and the
module is only loaded through a dynamic import in the interactive
branch, so the status line render path is unaffected.
The dev reload cursor test checked the first frame and the cursor callback
after fixed 25 ms delays, which a busy machine can outrun. It now retries the
assertions until they pass (up to 3 s) with a waitFor helper. Once they pass,
waitFor gives React two setImmediate turns: React draws a frame, then attaches
the new input listener in a scheduled effect, so a key sent the moment the
frame shows could reach no listener.
The App always passes the line selector its settings. The hotkey test now
does too, so it matches real use and keeps compiling if they become
required.
…resume

- The retry prompt takes the terminal and the supervisor takes the
  function that runs each child, so both run in tests: Enter retries, q
  gives up, other keys wait, and the supervisor relaunches the same command
  with a snapshot path and removes the snapshot at the end.
- A test renders the App as a reloaded child: it opens on the snapshot's
  line editor with the unsaved settings, and ctrl+r writes a new snapshot
  of where it is. Startup checks on Claude Code, package managers and
  fonts are stubbed.
The supervisor kept its snapshot at os.tmpdir()/ccstatusline-dev-reload-
<pid>.json and removed it only when it returned normally. The TUI it starts
restores whatever is at that path, so:

- a snapshot left by a supervisor that was killed came back in a later run
  that got the same PID, as current unsaved settings, and ctrl+s then wrote
  it over settings.json;
- on a shared /tmp another local user could plant the file, e.g. with a
  Custom Command widget, and have it restored the same way.

Each run now creates its own directory with mkdtemp (new, mode 0700) and
keeps the snapshot inside, so the first child can only find what this run
wrote. The whole directory is removed when the supervisor ends.
The supervisor ran the TUI with spawnSync and handled no signals. SIGTERM,
SIGINT or SIGHUP sent to the supervisor alone (e.g. kill <pid>) ended it
but not the TUI, which kept running with PPID 1, out of the foreground;
under Bun it later printed an EIO stack trace over the shell prompt. The
snapshot was left behind too, also when closing the terminal ended both.

The TUI now runs as an asynchronous child, so the supervisor can handle
signals while it runs. On any of the three it passes the signal on, waits
for the TUI to exit (so the shell gets the terminal back after the TUI has
restored it), removes the snapshot directory and exits with 128 + the
signal number. A TUI that ends this way is not a crash, so no retry prompt
is shown. At the prompt there is no TUI to wait for, so the supervisor
cleans up and exits straight away.

The supervisor's process (prompt terminal, signals, exit) is a parameter
defaulting to process, like the child launcher, so tests can drive it.
main now has the same helper in src/tui/__tests__/helpers/wait-for-ink.ts,
so the file's own copy of waitFor and letReactCatchUp goes.
# Conflicts:
#	src/tui/App.tsx
#	src/tui/components/ItemsEditor.tsx
The supervisor hands every TUI it starts the same snapshot path, including
the one it relaunches when Enter is pressed at "Reload failed". The snapshot
was only ever written by ctrl+r and never refreshed or removed afterwards,
so it outlived any save made after that ctrl+r.

So after ctrl+r, a save, and then a crash, the retry restored the settings
from before the save, paired with the original from before the save. The
TUI showed widgets that had been deleted and saved, offered Save & Exit for
changes that weren't there, and the next save wrote the older settings back
over the newer file.

Each change to originalSettings after the first load follows a write to the
settings file (ctrl+s, Save & Exit, or an install, update or uninstall
recording itself), so the TUI now deletes the snapshot when that happens. A
retry after such a write loads the file. A crash before any write, the case
the retry is for, still resumes from the snapshot in place.
@sirmalloc
sirmalloc merged commit 9e4acc8 into sirmalloc:main Oct 26, 2026
3 checks passed
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 27, 2026
#4 merged upstream as sirmalloc#700, with sirmalloc's guard and live menu
selections, so this branch now builds on main rather than #4's branch.
#4's ItemsEditor effect that reports the selected row came in twice,
once from main and once from this branch, where the picker code moved
it; one copy stays.
axisrow added a commit to axisrow/ccstatusline that referenced this pull request Oct 27, 2026
…it names, sirmalloc#717 claude-settings test isolation, sirmalloc#705 jj detached HEAD, sirmalloc#703 hooks save warning, sirmalloc#700 dev reload, sirmalloc#698 renderer merge ends at hidden widget, sirmalloc#697 block reset timer under-limit hide, sirmalloc#745 dev-deps bumps

Conflict policy: upstream infra wins, fork features ported on top (fork-only
src/daemon/, src/widgets/editors/*, parity fixtures and fork widgets kept).
Notable joins: sirmalloc#703 returns SkippedWidgetHooks through the fork's
saveSettingsTo wrapper; sirmalloc#697 keeps the fork's zero hide state alongside
upstream's under-limit state; sirmalloc#698 replaces the fork's merge-glue palette
semantics with upstream's merge-ends-at-unmerged-empty-widget rule;
dev-reload (ctrl+r) composes with the fork's first-run preset picker.

Co-Authored-By: Claude Code <[email protected]>
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Oct 29, 2026
Upstream: Extra Usage Limit widget (sirmalloc#756), live widget preview while picking (sirmalloc#755), Fast Mode widget (sirmalloc#754), transcript block fallback fixes (sirmalloc#752), editor keys on a global install (sirmalloc#751), git cache TTL (sirmalloc#750), npx cached copy skip (sirmalloc#749), Block Usage hide at 100% (sirmalloc#747), Block Reset Timer hide until 100% (sirmalloc#697), fish-style branch abbreviation (sirmalloc#614), jj detached HEAD (sirmalloc#705), hooks save warning (sirmalloc#703), dev reload (sirmalloc#700), merge ends at a hidden widget (sirmalloc#698), Block Forecast and Block Limit Timer widgets (sirmalloc#699), contributor guide (sirmalloc#706), POSIX path case fix (sirmalloc#707), whitespace-distinct conflict filenames (sirmalloc#709), Quick Start reorder (sirmalloc#715), bunx launch guidance (sirmalloc#714), jj diff summary counts (sirmalloc#711), powerline manual separators (sirmalloc#713), keychain credential by account (sirmalloc#532), Keychain probe timeout (sirmalloc#760), usage cache scoped per CLAUDE_CONFIG_DIR (sirmalloc#266), test isolation (sirmalloc#717, sirmalloc#753), dev-dependency bumps (sirmalloc#745)

Conflicts resolved per the standard-resolution table; fork deletions re-applied.

Hand edits outside conflicts:
- src/utils/global-command-resolution.ts: fork-rename (ccstatusline@ -> ccstatusline-usage@ in upstream's new bunx pattern)
- src/utils/__tests__/global-package-manager.test.ts: fork-rename (package dir ccstatusline-usage in upstream's new tests)
- src/utils/usage-fetch.ts: compat fix - cacheUsageData gained a fetchedAtMs arg (upstream sirmalloc#266); adapted the fork-only fetchFreshUsageData call site
- src/utils/__tests__/usage-fetch.test.ts: compat fixes - cache/lock now hashed per CLAUDE_CONFIG_DIR (usageFilePath helper) in four fork tests; upstream fableUsage renamed to fork's weeklyFableUsage; seedUsageCache takes TokenHome
- src/utils/__tests__/usage-prefetch.test.ts: compat fix - mockFetchUsageData renamed to upstream's mockFetchUsage in cleanly-merged tests
- README.md: took upstream's Quick Start move (sirmalloc#715) and dropped the fork's old copy; fork-rename of its ccstatusline@ specifiers; links to fork-deleted docs (docs/USAGE.md, docs/WINDOWS.md) replaced with the fork's inline text
- CONTRIBUTING.md: links to the fork-deleted docs/DEVELOPMENT.md pointed at the README Development section
- docs/test-retirements.md: ledger entries for the 12 upstream test renames (sirmalloc#697, sirmalloc#698, sirmalloc#700, sirmalloc#705, sirmalloc#713, sirmalloc#760)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants