diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3164d0..4a9b8da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Changelog Unreleased +- **A deliberate quit no longer saves the quit word as the next prompt + (gh #62).** The drafts subsystem had one live exit: `Ctrl-D` only quits + on an empty line, so the only way to leave with text in the editor was + to clear it and type `:q` - and that word was flushed to the cwd-keyed + draft sidecar as it was typed, nothing removed it on the way out, and + the exit-proc flush wrote it straight back. Every later start in that + directory restored `:q` into the editor, where it also blocked `Ctrl-D` + until you deleted it. Quitting is not the unexpected shutdown drafts + exist for: the quit paths now drop both sidecar scopes and gate the + flusher, so the editor's text leaves with the session. A killed process + still restores its half-typed prompt exactly as before. - **3code owns the tty's output discipline (gh #54).** A shell left `-opost` by a crashed CLI or `stty -opost` used to break every wrapped row: with `OPOST` off, `ONLCR` is inert, so the bare `\n` 3code writes diff --git a/src/threecode.nim b/src/threecode.nim index abd844a8..61b698c0 100644 --- a/src/threecode.nim +++ b/src/threecode.nim @@ -730,6 +730,15 @@ proc main() = signal(SIGHUP, signalCleanup) editor.completionCallback = proc(ed: minline.LineEditor): seq[string] = completionFor(ed.lineText) + proc quitDiscardingDraft() = + ## A deliberate exit (`:q` and friends, Ctrl-D, a queued quit event, the + ## oneshot paths) abandons the editor with the session instead of + ## handing its text to the next run in this directory. The gate stops + ## the flusher thread and the exit-proc `flushDraftNow` from writing the + ## quit word back out; `clearDraft` drops what the flusher already + ## persisted while it was being typed. + fatprompt.discardDrafts() + clearDraft(session) # The bootstrap provider wizard (and every later prompt) reads via the # input thread, whose startup guard is `inputEditor != nil`. Wire the # module-global pointers before the wizard can run, or the first-run @@ -1010,6 +1019,7 @@ proc main() = if prompt != "" and runInitialPrompt(prompt): # quit, not return: unwinding `editor` while the input thread still # holds it is the Windows oneshot SIGSEGV (illegal storage access). + quitDiscardingDraft() quit(0) # Oneshot: a prompt given on the command line runs once and exits. Only # -i/--interactive keeps the REPL open afterward (and no prompt at all @@ -1021,6 +1031,7 @@ proc main() = emitFatPromptEvent clearBarEvent() termengine.renderFooter(clearFooterFrame(), inputThreadRunning, addr editor) + quitDiscardingDraft() quit(0) while true: var done = false @@ -1102,6 +1113,7 @@ proc main() = # clean exit doesn't restore text the user already sent. clearDraft(session) if runTurnWithSafetyNet(): break + quitDiscardingDraft() pluginsShutdown() except IOError as e: diff --git a/src/threecode/fatprompt/runtime.nim b/src/threecode/fatprompt/runtime.nim index 9422945d..e14156a5 100644 --- a/src/threecode/fatprompt/runtime.nim +++ b/src/threecode/fatprompt/runtime.nim @@ -133,6 +133,12 @@ var draftDirty: Atomic[bool] var draftFlusherStop: Atomic[bool] var draftFlusherThread: Thread[void] var draftFlusherRunning = false +var draftsDiscarded: Atomic[bool] + ## A deliberate quit (`:q`, Ctrl-D) is not the unexpected shutdown the + ## draft sidecar exists for: whatever is in the editor leaves with the + ## session. Set by the controller on the way out so neither the flusher + ## thread nor the exit-proc `flushDraftNow` can write the quit word back + ## out as the next run's restored prompt. var inputState*: InputState var inputStateLock*: Lock @@ -1377,6 +1383,7 @@ proc stopBarTick*(): int = proc snapshotAndSaveDraft() = ## Take a consistent (session path, editor text) snapshot and persist it as ## a draft. Called from the flusher thread and the synchronous final flush. + ## No-op once the controller has discarded drafts for a deliberate quit. ## The editor's buffer belongs to the terminal write lock (the input thread ## mutates `line.text` under it via preMutate/postMutate, and the GUI thread ## reads it there in renderFooter); reading it under inputStateLock alone @@ -1388,6 +1395,7 @@ proc snapshotAndSaveDraft() = ## flush triggered from a signal handler (cleanup) non-blocking: the ## snapshot is skipped and retried on the next tick when either lock is ## held. + if draftsDiscarded.load(moAcquire): return var sessionPtr: ptr Session = nil var text = "" if tryAcquire(inputStateLock): @@ -1421,6 +1429,15 @@ proc ensureDraftFlusherStarted*() = createThread(draftFlusherThread, draftFlusherLoop) draftFlusherRunning = true +proc discardDrafts*() = + ## Stop persisting prompt drafts from this point on. Called by the + ## controller when the user quits deliberately: the editor's contents are + ## abandoned with the session rather than restored into the next one. + ## Idempotent; pair with `session.clearDraft` to also drop what the flusher + ## already wrote. + draftsDiscarded.store(true, moRelease) + draftDirty.store(false, moRelease) + proc stopDraftFlusher*() = ## Signal the flusher thread to stop (non-blocking; does not join). Safe to ## call from any thread, including the flusher thread itself, since it never @@ -1431,7 +1448,10 @@ proc flushDraftNow*() = ## Persist the current draft synchronously. Called from cleanup as the last ## reliable save before teardown. No-op when no session path exists yet; ## otherwise writes unconditionally — we are shutting down, so a redundant - ## write is cheap insurance against losing the in-flight prompt. + ## write is cheap insurance against losing the in-flight prompt. Also a + ## no-op after `discardDrafts`: a deliberate quit must not re-create the + ## sidecar the controller just removed. + if draftsDiscarded.load(moAcquire): return draftDirty.store(false, moRelease) {.cast(gcsafe).}: try: snapshotAndSaveDraft() diff --git a/tests/tty/test_tty_functional.nim b/tests/tty/test_tty_functional.nim index d454ffe7..0a718453 100644 --- a/tests/tty/test_tty_functional.nim +++ b/tests/tty/test_tty_functional.nim @@ -1701,6 +1701,41 @@ max-tokens = "8192" defer: tty2.close() tty2.expect draftText + test "a deliberate quit discards the draft instead of saving the quit word": + # Regression (issue #62): the only prompt a clean quit ever persisted was + # the quit word itself. `:q` was flushed to the cwd-keyed draft sidecar + # while it was being typed, no quit path removed it, and the exit-proc + # flush wrote it straight back — so every later start in that directory + # restored `:q` into the editor, and Ctrl-D could no longer leave (it + # only quits on an empty line). A deliberate quit is not the unexpected + # shutdown drafts exist for: the editor's text leaves with the session. + let root = newFixture("draft_discarded_on_quit") + writeConfiguredProvider(root) + writeStubResponses(root, %*[]) + let tty1 = startStub(root) + tty1.expect "❯" + tty1.send ":q\n" + tty1.expectExit(0, timeoutMs = 5000) + # No sidecar survives in either scope (pending cwd-keyed, session-keyed) + # — the drafts tree must be empty, not merely moved. + var sidecars = 0 + let draftsDir = root / "data" / "3code" / "drafts" + if dirExists(draftsDir): + for p in walkDirRec(draftsDir): + if p.endsWith(".prompt"): inc sidecars + check sidecars == 0 + + # The next start in the same directory comes up with an empty editor: no + # restored `:q`, so Ctrl-D still quits. The welcome banner itself mentions + # `:q`, so assert the live prompt row rather than the whole screen. + let tty2 = startStub(root) + defer: tty2.close() + tty2.expect "❯" + tty2.drain 400 + check ":q" notin tty2.rows()[tty2.rowContaining("❯")] + tty2.ctrlD() + tty2.expectExit(0, timeoutMs = 5000) + # The quiet-watch thread now only fires a hard timeout; there is no # intermediate hourglass label. Wait briefly so the spinner is active # before exercising cancel.