Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/threecode.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1021,6 +1031,7 @@ proc main() =
emitFatPromptEvent clearBarEvent()
termengine.renderFooter(clearFooterFrame(), inputThreadRunning,
addr editor)
quitDiscardingDraft()
quit(0)
while true:
var done = false
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 21 additions & 1 deletion src/threecode/fatprompt/runtime.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions tests/tty/test_tty_functional.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading