Skip to content

Commit 302cbdf

Browse files
VibeWriter Userclaude
andcommitted
feat: add GUI onboarding walkthrough + README single-session docs
Add a 6-slide first-run onboarding overlay to the GUI (Welcome, How It Works, Safety Net, One Project at a Time, Token Usage, Ready to Go). Uses localStorage to show once, dismissible via Skip/Get Started/Escape/ click-outside. Follows existing modal overlay pattern. Add "One Session at a Time" section to README explaining the single-session enforcement design (singleton guard + lock file). Also includes pre-existing fixes: stdin-only prompt delivery in claude.js (removes -p flag Windows mangling), gui/server.js tweaks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b360310 commit 302cbdf

10 files changed

Lines changed: 267 additions & 40 deletions

File tree

.claude/memory/claude-integration.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,9 @@ Assumes CLAUDE.md loaded. Subprocess wrapper in `src/claude.js`.
99
| `DEFAULT_TIMEOUT` | 45 min (2,700,000 ms) — overridable via `--timeout` |
1010
| `DEFAULT_RETRIES` | 3 (total attempts = 4) |
1111
| `RETRY_DELAY` | 10,000 ms between retries |
12-
| `STDIN_THRESHOLD` | 8,000 chars → switches to stdin pipe mode |
12+
## Prompt Delivery
1313

14-
## Spawn Modes
15-
16-
- **Short** (< 8000 chars): `-p "prompt"` flag, stdin `'ignore'`
17-
- **Long** (≥ 8000 chars): stdin `'pipe'`, write prompt + end
14+
All prompts delivered via **stdin pipe** (never `-p` flag). On Windows, `shell: true` causes `cmd.exe` to silently mangle special characters in `-p` arguments. Stdin is binary-safe on all platforms.
1815

1916
## Platform Rules
2017

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ test/
106106
gui/
107107
server.js # Node.js HTTP server + Chrome app-mode launcher
108108
resources/
109-
index.html # Single-page app with 5 screen sections
109+
index.html # Single-page app with 5 screen sections + first-run onboarding overlay
110110
styles.css # Dark theme CSS (extracted from dashboard-html.js)
111111
logic.js # Pure functions (buildCommand, parseCliOutput, formatMs, etc.)
112112
app.js # State machine + fetch API calls to server.js endpoints
@@ -265,7 +265,7 @@ NightyTidy creates these files/artifacts in the project it runs against:
265265
- **Usage-limit resume**: When a rate limit is detected during an interactive CLI run, state is saved to `nightytidy-run-state.json` (same format as orchestrator mode). The user can close the terminal and resume later with `--resume`. The backoff schedule covers ~9.9 hours total (2min → 5min → 15min → 30min → 1hr → 2hr × 4). GUI's pause overlay includes a "Save & Close" button that exits cleanly for later resume.
266266
- **Prompt integrity check**: `executor.js` computes SHA-256 of all step prompts and compares against `STEPS_HASH`. After editing any markdown file in `src/prompts/steps/` or `src/prompts/specials/`, recompute and update the hash in `executor.js`. Warns but does not block (user may have legitimate prompt changes).
267267
- **`--dangerously-skip-permissions`**: Required for non-interactive Claude Code subprocess calls. NightyTidy is the permission layer — it controls what prompts are sent and operates on a safety branch.
268-
- **Prompt delivery threshold**: Prompts longer than 8000 chars (`STDIN_THRESHOLD` in `claude.js`) are piped via stdin instead of passed as a `-p` argument. This avoids OS command-line length limits. If prompts fail with argument-too-long errors, check this threshold.
268+
- **Prompt delivery via stdin**: All prompts are piped to Claude Code via stdin (never the `-p` flag). On Windows, `shell: true` causes Node.js to embed `-p` arguments directly in the `cmd.exe` command string, where special characters (`|`, `&`, `(`, `)`, `<`, `>`) get silently mangled. Stdin is binary-safe and immune to shell escaping on all platforms.
269269
- **Env var allowlist**: `cleanEnv()` in `env.js` uses an explicit allowlist (system paths, locale, Anthropic/Claude/Git prefixes) instead of a blocklist. Unknown env vars are filtered out and logged via `debug()`. `CLAUDECODE` is explicitly blocked. Tests in `env.test.js`.
270270
- **Branch guard**: `ensureOnBranch()` in `git.js` is called before and after every step execution in `runStep()`. If Claude Code switched to a different branch during a step, it commits any uncommitted work, checks out the run branch, and merges the stray branch back. On merge conflict, the merge is aborted (step work preserved on the stray branch). This prevents the "branch drift" problem where Claude Code creates its own branches, scattering commits across multiple branches.
271271
- **Gitleaks CI scan**: `.github/workflows/ci.yml` runs `gitleaks/gitleaks-action@v2` on every push/PR to detect committed secrets.

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ A full 33-step run is a serious workload — expect **6 to 8 hours** with Claude
5050

5151
Running fewer steps per session is a perfectly valid workflow — you'll get the same results, just spread over multiple nights.
5252

53+
## One Session at a Time
54+
55+
NightyTidy enforces single-session execution — only one improvement run can be active at a time, whether through the GUI or CLI. This is by design: running multiple concurrent AI sessions against the same codebase would create conflicting changes, broken merges, and unreliable results.
56+
57+
- **GUI**: A singleton guard ensures only one NightyTidy window can be open. Launching again focuses the existing window.
58+
- **CLI**: An atomic lock file (`nightytidy.lock`) prevents concurrent runs. If a previous run was interrupted, you'll be prompted to override or resume.
59+
60+
If you need to run against multiple projects, use separate terminal sessions — the lock is per-project, not global.
61+
5362
## Desktop GUI
5463

5564
The GUI is the primary way to use NightyTidy. It wraps the CLI orchestrator in a five-screen visual workflow.

gui/resources/app.js

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,76 @@ let lastRenderedOutput = '';
8686
let lastOutputChangeTime = 0;
8787
const WORKING_INDICATOR_DELAY_MS = 8000; // Show after 8s of no output change
8888

89+
// ── Onboarding (first-run walkthrough) ──────────────────────────────
90+
91+
const ONBOARDING_STORAGE_KEY = 'nightytidy-onboarding-seen';
92+
const ONBOARDING_SLIDE_COUNT = 6;
93+
let onboardingSlide = 0;
94+
let onboardingFocusTrapCleanup = null;
95+
96+
function shouldShowOnboarding() {
97+
try {
98+
return !localStorage.getItem(ONBOARDING_STORAGE_KEY);
99+
} catch {
100+
return false; // localStorage unavailable — skip onboarding
101+
}
102+
}
103+
104+
function markOnboardingSeen() {
105+
try {
106+
localStorage.setItem(ONBOARDING_STORAGE_KEY, '1');
107+
} catch { /* ignore */ }
108+
}
109+
110+
function showOnboarding() {
111+
onboardingSlide = 0;
112+
renderOnboardingSlide();
113+
const overlay = document.getElementById('onboarding-overlay');
114+
overlay.classList.remove('hidden');
115+
onboardingFocusTrapCleanup = trapFocus(overlay.querySelector('.modal'));
116+
document.getElementById('btn-onboarding-next').focus();
117+
}
118+
119+
function dismissOnboarding() {
120+
markOnboardingSeen();
121+
const overlay = document.getElementById('onboarding-overlay');
122+
overlay.classList.add('hidden');
123+
if (onboardingFocusTrapCleanup) {
124+
onboardingFocusTrapCleanup();
125+
onboardingFocusTrapCleanup = null;
126+
}
127+
document.getElementById('btn-select-folder').focus();
128+
}
129+
130+
function nextOnboardingSlide() {
131+
if (onboardingSlide >= ONBOARDING_SLIDE_COUNT - 1) {
132+
dismissOnboarding();
133+
return;
134+
}
135+
onboardingSlide++;
136+
renderOnboardingSlide();
137+
}
138+
139+
function goToOnboardingSlide(index) {
140+
if (index < 0 || index >= ONBOARDING_SLIDE_COUNT) return;
141+
onboardingSlide = index;
142+
renderOnboardingSlide();
143+
}
144+
145+
function renderOnboardingSlide() {
146+
const slides = document.querySelectorAll('.onboarding-slide');
147+
slides.forEach((s, i) => {
148+
s.classList.toggle('active', i === onboardingSlide);
149+
});
150+
const dots = document.querySelectorAll('.onboarding-dot');
151+
dots.forEach((d, i) => {
152+
d.classList.toggle('active', i === onboardingSlide);
153+
d.setAttribute('aria-selected', i === onboardingSlide ? 'true' : 'false');
154+
});
155+
const nextBtn = document.getElementById('btn-onboarding-next');
156+
nextBtn.textContent = onboardingSlide === ONBOARDING_SLIDE_COUNT - 1 ? 'Get Started' : 'Next';
157+
}
158+
89159
// ── State ──────────────────────────────────────────────────────────
90160

91161
const SCREENS = {
@@ -2141,9 +2211,24 @@ function bindEvents() {
21412211
window.close();
21422212
});
21432213

2214+
// Onboarding
2215+
document.getElementById('btn-onboarding-skip').addEventListener('click', dismissOnboarding);
2216+
document.getElementById('btn-onboarding-next').addEventListener('click', nextOnboardingSlide);
2217+
document.querySelectorAll('.onboarding-dot').forEach(dot => {
2218+
dot.addEventListener('click', () => {
2219+
goToOnboardingSlide(parseInt(dot.dataset.dot, 10));
2220+
});
2221+
});
2222+
21442223
// Keyboard accessibility — Escape key to close drawer and modals
21452224
document.addEventListener('keydown', (e) => {
21462225
if (e.key === 'Escape') {
2226+
// Onboarding overlay — Escape dismisses
2227+
const onboardingOverlay = document.getElementById('onboarding-overlay');
2228+
if (!onboardingOverlay.classList.contains('hidden')) {
2229+
dismissOnboarding();
2230+
return;
2231+
}
21472232
const confirmOverlay = document.getElementById('confirm-stop-overlay');
21482233
if (!confirmOverlay.classList.contains('hidden')) {
21492234
cancelStopRun();
@@ -2157,7 +2242,12 @@ function bindEvents() {
21572242
}
21582243
});
21592244

2160-
// Click outside modal to close (confirm dialog only)
2245+
// Click outside modal to close
2246+
document.getElementById('onboarding-overlay').addEventListener('click', (e) => {
2247+
if (e.target.id === 'onboarding-overlay') {
2248+
dismissOnboarding();
2249+
}
2250+
});
21612251
document.getElementById('confirm-stop-overlay').addEventListener('click', (e) => {
21622252
if (e.target.id === 'confirm-stop-overlay') {
21632253
cancelStopRun();
@@ -2357,6 +2447,11 @@ document.addEventListener('DOMContentLoaded', () => {
23572447
bindEvents();
23582448
showScreen(SCREENS.SETUP);
23592449

2450+
// Show first-run onboarding (before user can interact with setup)
2451+
if (shouldShowOnboarding()) {
2452+
showOnboarding();
2453+
}
2454+
23602455
// Start heartbeat systems immediately (fire-and-forget)
23612456
initHeartbeat();
23622457

gui/resources/index.html

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,60 @@ <h2 id="pause-title">Rate Limit Reached</h2>
218218
</div>
219219
</div>
220220

221+
<!-- ═══ Onboarding Overlay (first-run only) ═══ -->
222+
<div class="modal-overlay hidden" id="onboarding-overlay" role="dialog" aria-modal="true" aria-labelledby="onboarding-title">
223+
<div class="modal modal-onboarding">
224+
<div class="onboarding-slide active" data-slide="0">
225+
<div class="onboarding-icon" aria-hidden="true">&#9790;</div>
226+
<h2 id="onboarding-title">Welcome to NightyTidy</h2>
227+
<p>AI-powered overnight codebase improvement. Pick what to improve, let Claude Code do the work, wake up to cleaner code.</p>
228+
</div>
229+
230+
<div class="onboarding-slide" data-slide="1">
231+
<div class="onboarding-icon" aria-hidden="true">&#9881;</div>
232+
<h2>How It Works</h2>
233+
<p>Choose from 33 improvement steps &mdash; documentation, testing, security, performance, and more. NightyTidy runs each one through Claude Code and generates a detailed report of everything that changed.</p>
234+
</div>
235+
236+
<div class="onboarding-slide" data-slide="2">
237+
<div class="onboarding-icon" aria-hidden="true">&#128274;</div>
238+
<h2>Built-In Safety Net</h2>
239+
<p>Every run creates a git branch and a safety tag before touching your code. If you don't like the changes, one command undoes everything. Your main branch stays untouched until you're ready to merge.</p>
240+
</div>
241+
242+
<div class="onboarding-slide" data-slide="3">
243+
<div class="onboarding-icon" aria-hidden="true">&#128736;</div>
244+
<h2>One Project at a Time</h2>
245+
<p>NightyTidy runs one session at a time &mdash; don't open multiple windows for different repos. Finish one run before starting the next. This prevents conflicts and keeps everything clean.</p>
246+
</div>
247+
248+
<div class="onboarding-slide" data-slide="4">
249+
<div class="onboarding-icon" aria-hidden="true">&#9889;</div>
250+
<h2>Token Usage</h2>
251+
<p>A full 33-step run is very token-heavy. You'll almost certainly need the <strong>Max plan</strong> with plenty of usage left. If you're not on Max, run 8&ndash;10 steps at a time and wait for your limits to reset between batches.</p>
252+
</div>
253+
254+
<div class="onboarding-slide" data-slide="5">
255+
<div class="onboarding-icon" aria-hidden="true">&#127769;</div>
256+
<h2>Ready to Go</h2>
257+
<p>Select a project folder, pick your steps, and let NightyTidy work overnight. If you hit a rate limit mid-run, it pauses automatically &mdash; you can resume later right where you left off.</p>
258+
</div>
259+
260+
<div class="onboarding-nav">
261+
<button class="link-btn" id="btn-onboarding-skip" type="button">Skip</button>
262+
<div class="onboarding-dots" id="onboarding-dots" role="tablist" aria-label="Onboarding slides">
263+
<button class="onboarding-dot active" data-dot="0" role="tab" aria-selected="true" aria-label="Slide 1" type="button"></button>
264+
<button class="onboarding-dot" data-dot="1" role="tab" aria-selected="false" aria-label="Slide 2" type="button"></button>
265+
<button class="onboarding-dot" data-dot="2" role="tab" aria-selected="false" aria-label="Slide 3" type="button"></button>
266+
<button class="onboarding-dot" data-dot="3" role="tab" aria-selected="false" aria-label="Slide 4" type="button"></button>
267+
<button class="onboarding-dot" data-dot="4" role="tab" aria-selected="false" aria-label="Slide 5" type="button"></button>
268+
<button class="onboarding-dot" data-dot="5" role="tab" aria-selected="false" aria-label="Slide 6" type="button"></button>
269+
</div>
270+
<button class="btn btn-primary btn-sm" id="btn-onboarding-next" type="button">Next</button>
271+
</div>
272+
</div>
273+
</div>
274+
221275
<script src="/marked.umd.js"></script>
222276
<script src="/logic.js"></script>
223277
<script src="/app.js"></script>

gui/resources/styles.css

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,69 @@ body {
878878
50% { opacity: 0.6; }
879879
}
880880

881+
/* ── Onboarding (first-run walkthrough) ── */
882+
.modal-onboarding {
883+
max-width: 480px;
884+
text-align: center;
885+
padding: 36px 32px 24px;
886+
}
887+
.onboarding-icon {
888+
font-size: 2.8rem;
889+
margin-bottom: 16px;
890+
line-height: 1;
891+
}
892+
.onboarding-slide {
893+
display: none;
894+
}
895+
.onboarding-slide.active {
896+
display: block;
897+
animation: onboarding-fade 0.25s ease-out;
898+
}
899+
@keyframes onboarding-fade {
900+
from { opacity: 0; transform: translateY(8px); }
901+
to { opacity: 1; transform: translateY(0); }
902+
}
903+
.onboarding-slide h2 {
904+
font-size: 1.15rem;
905+
color: var(--text);
906+
margin-bottom: 12px;
907+
}
908+
.onboarding-slide p {
909+
color: var(--text-dim);
910+
font-size: 0.9rem;
911+
line-height: 1.6;
912+
margin: 0;
913+
}
914+
.onboarding-nav {
915+
display: flex;
916+
align-items: center;
917+
justify-content: space-between;
918+
margin-top: 28px;
919+
gap: 12px;
920+
}
921+
.onboarding-dots {
922+
display: flex;
923+
gap: 8px;
924+
align-items: center;
925+
}
926+
.onboarding-dot {
927+
width: 8px;
928+
height: 8px;
929+
border-radius: 50%;
930+
background: var(--border);
931+
border: none;
932+
padding: 0;
933+
cursor: pointer;
934+
transition: background 0.2s, transform 0.15s;
935+
}
936+
.onboarding-dot:hover {
937+
background: var(--text-dim);
938+
}
939+
.onboarding-dot.active {
940+
background: var(--cyan);
941+
transform: scale(1.25);
942+
}
943+
881944
/* ── Side Drawer (step output / report viewer) ── */
882945
.step-drawer {
883946
position: fixed;

gui/server.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -785,8 +785,18 @@ function cleanup() {
785785
// Singleton check — exit early if another GUI is already running
786786
const existingUrl = await checkExistingInstance();
787787
if (existingUrl) {
788-
console.log(`NightyTidy GUI is already running at ${existingUrl}`);
789-
console.log('Opening existing window...');
788+
console.log('');
789+
console.log(' ⚠ NightyTidy GUI is already running!');
790+
console.log('');
791+
console.log(' NightyTidy is designed to work on one project at a time.');
792+
console.log(' Running multiple instances can cause conflicts and broken runs.');
793+
console.log('');
794+
console.log(' If you need to work on a different project, close the existing');
795+
console.log(' window first, then start the GUI again.');
796+
console.log('');
797+
console.log(` Existing instance: ${existingUrl}`);
798+
console.log(' Focusing existing window...');
799+
console.log('');
790800
launchChrome(existingUrl);
791801
process.exit(0);
792802
}

src/claude.js

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ import { cleanEnv } from './env.js';
5252
const DEFAULT_TIMEOUT = 45 * 60 * 1000; // 45 minutes
5353
const DEFAULT_RETRIES = 3;
5454
const RETRY_DELAY = 10000; // 10 seconds
55-
const STDIN_THRESHOLD = 8000; // chars
5655
const SIGKILL_DELAY = 5000; // grace period before SIGKILL after initial kill
5756
export const INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes — no stdout or stderr data
5857

@@ -186,35 +185,25 @@ export function sleep(ms, signal) {
186185
* @returns {import('child_process').ChildProcess} The spawned child process
187186
*/
188187
function spawnClaude(prompt, cwd, useShell = false, continueSession = false) {
189-
const useStdin = prompt.length > STDIN_THRESHOLD;
190-
191-
// --dangerously-skip-permissions: required for non-interactive mode.
192-
// Without it, Claude Code blocks on tool permission prompts (Bash, Edit, etc.)
193-
// that cannot be approved without a TTY. NightyTidy is the permission layer —
194-
// it controls what prompts are sent and operates on a safety branch.
195-
// --output-format stream-json: streams NDJSON events in real-time as the
196-
// conversation progresses. Each line is a JSON object (assistant message,
197-
// tool use, etc.). The final line is a `result` event with total_cost_usd,
198-
// num_turns, duration_api_ms, and the response text in the `result` field.
199-
const args = useStdin
200-
? ['--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions']
201-
: ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
188+
// Always deliver prompts via stdin pipe. The previous approach used the -p flag
189+
// for short prompts, but on Windows (shell: true), cmd.exe silently mangles
190+
// special characters (|, &, (, ), <, >) in the command string, causing Claude
191+
// Code to receive a garbled or empty prompt and fall back to a generic greeting.
192+
// Stdin is binary-safe and immune to shell escaping issues on all platforms.
193+
const args = ['--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
202194
if (continueSession) args.push('--continue');
203-
const stdinMode = useStdin ? 'pipe' : 'ignore';
204195

205-
debug(`Spawn mode: ${useStdin ? 'stdin' : '-p flag'}, prompt length: ${prompt.length} chars`);
196+
debug(`Spawn: stdin pipe, prompt length: ${prompt.length} chars`);
206197

207198
const child = spawn('claude', args, {
208199
cwd,
209-
stdio: [stdinMode, 'pipe', 'pipe'],
200+
stdio: ['pipe', 'pipe', 'pipe'],
210201
shell: useShell,
211202
env: cleanEnv(),
212203
});
213204

214-
if (useStdin) {
215-
child.stdin.write(prompt);
216-
child.stdin.end();
217-
}
205+
child.stdin.write(prompt);
206+
child.stdin.end();
218207

219208
return child;
220209
}

0 commit comments

Comments
 (0)