diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c68808..9f00c45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed the session name entered in the Save & Switch dialog not being saved to the session that was closed (affected named connection profiles, projects with several sessions, and conversations switched with /resume inside the terminal), including when choosing Restart Advanced # 1.3.45 (2026-10-28) - Fixed edit-mode detection and switching while background agents or shells are listed under the status line # 1.3.42 (2026-10-22) diff --git a/pom.xml b/pom.xml index 9683e912..56f5e5b2 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.69-SNAPSHOT + 1.3.71-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index ce2e7540..955dd4c3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -22,9 +22,12 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import java.util.logging.Logger; import java.util.regex.Matcher; @@ -69,6 +72,23 @@ public class ClaudeSessionController { private static final int MAX_MODEL_DISCOVERY_ATTEMPTS = 1; + /** + * Slack for the filesystem's modification-time granularity when matching a + * session file against a launch/prompt time. FAT and some network + * filesystems round mtimes down to 1–2 s, so a file created just after a + * prompt — including a saved conversation resumed in-process — can be + * stamped before it. Applied as a second identity-scan pass (see + * {@link #refreshSessionIdentity()}). + */ + private static final long SESSION_MTIME_GRANULARITY_MS = 2_000L; + + /** + * Maximum number of session files written since a prompt inspected for the + * tab's own conversation. Reading a JSONL is not free, and the file of the + * running process is always among the most recently written ones. + */ + private static final int PROMPT_EVIDENCE_CANDIDATES = 8; + private final ClaudeSessionModel model; /** @@ -80,8 +100,19 @@ public class ClaudeSessionController { private final ScreenContentDetector screenContentDetector = new ScreenContentDetector(); - private ClaudeProcess claudeProcess; + private volatile ClaudeProcess claudeProcess; private PtyTtyConnector connector; + private volatile java.util.function.Consumer ptyWriteHook; + + /** Package-private for tests: runs whenever the PTY child is killed. */ + private volatile Runnable ptyKillHook; + + /** + * Package-private for tests: runs just before the stop path snapshots the + * session claims, so a concurrent process exit can be interleaved there. + */ + private volatile Runnable claimSnapshotHook; + private volatile java.util.function.Consumer ptyTargetHook; private java.io.File workingDir; private volatile JediTermWidget terminalWidget; @@ -104,6 +135,505 @@ public class ClaudeSessionController { /** {@code true} once the first byte/line from the PTY has been received. */ private volatile boolean firstOutputReceived = false; + /** + * Controllers whose PTY process is currently running. Tabs that share a + * project / config store use this set to publish (and claim) their own + * session before another tab decides which file to rename. + */ + private static final Set LIVE_SESSIONS = + ConcurrentHashMap.newKeySet(); + + /** + * A session file, identified by its store: the same session ID can exist in + * several config dirs / projects (e.g. a JSONL copied between stores), and + * a claim must only hide the file it actually belongs to. + */ + private record SessionKey(java.nio.file.Path configDir, + java.nio.file.Path projectDir, + String sessionId) { } + + /** Session file → live controllers running exactly that file. */ + private static final Map SESSION_CLAIMS = + new ConcurrentHashMap<>(); + + /** + * Live controllers writing one session file, split by how strong their hold + * on it is. + * + *
    + *
  • {@link #owners} — the tab knew this conversation at launch + * ({@code --continue}/{@code --resume}) and no other live tab held it + * when the claim was made. Only an owner may treat the file as + * established, and only ownership elsewhere makes the file foreign to + * this tab.
  • + *
  • {@code provisional} (a holder in neither set) — a {@code NEW} tab + * guessed that this file is its own. A guess is not ownership: it must + * never make the file look foreign to the tab that established it, + * nor be displaced merely because another unresolved tab follows the + * same file (with identical prompts the evidence cannot tell the two + * apart).
  • + *
  • {@link #borrowed} — the tab joined a file another live tab already + * held (an in-process resume into a sibling's conversation, or a NEW + * tab following it until its own file appears).
  • + *
+ */ + private static final class SessionClaims { + final Set holders = ConcurrentHashMap.newKeySet(); + final Set owners = ConcurrentHashMap.newKeySet(); + final Set borrowed = ConcurrentHashMap.newKeySet(); + } + + /** + * Worker for the periodic session-store scans, shared by all controllers so + * that the Swing EDT never enumerates the session history. + */ + private static final java.util.concurrent.ExecutorService IDENTITY_SCANNER = + java.util.concurrent.Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "session-id-scan"); + t.setDaemon(true); + return t; + }); + + /** + * Guards {@link #claudeProcess} replacement against a late exit callback: + * every started process gets a new {@link #processGeneration}, and the + * callback only releases ownership when its generation is still current. + * + *

{@link #processGeneration} is volatile for the senders that check it + * while holding {@link #terminalInputLine}: the stop path resets the + * recorder under the ownership lock, so that lock cannot be taken there + * (see {@link #recorderBelongsTo(long)}). + */ + private final Object processOwnershipLock = new Object(); + private volatile long processGeneration; + + /** + * Monotonic scan counter. Scans of the same process run can overlap (the + * dialog resolves synchronously while the worker scans); a result is only + * published if no newer scan published before it. + */ + private long scanSequence; + private long lastPublishedScanId; + + /** True while a background identity scan of this controller is queued. */ + private final java.util.concurrent.atomic.AtomicBoolean identityScanPending = + new java.util.concurrent.atomic.AtomicBoolean(); + + /** + * Effective Claude config dir of the running session, captured at launch. + * Used so that renaming targets the same store the process writes to. + */ + private volatile java.nio.file.Path activeConfigDir; + + /** + * Session ID known at launch: the resume ID for {@link SessionMode#RESUME_SPECIFIC} + * and the newest existing session for {@link SessionMode#CONTINUE_LAST}. For + * {@link SessionMode#NEW} this is {@code null} until the session file appears. + */ + private volatile String activeSessionId; + + /** + * Session IDs that already existed when the current session was launched. + * They are only ineligible for an unresolved {@code NEW} session and only + * before the plugin has submitted a prompt (see + * {@link #refreshSessionIdentity()}). + */ + private volatile Set preStartSessionIds = Set.of(); + + /** + * {@code true} when the session ID was known before the first prompt (a + * {@code --continue}/{@code --resume} launch). A {@code NEW} session has no + * identity up front, so the file it later adopts is only a guess until the + * tab proves the file is its own — see {@link #claimSession}. + */ + private volatile boolean launchIdentityKnown; + + /** + * Session ID the process was launched with ({@code --continue} or + * {@code --resume}), or {@code null} for a {@code NEW} launch. Kept after + * the tracked identity changes: when a prompt submitted later turns out to + * have been appended to the launch conversation, that record proves the + * conversation never actually switched (see + * {@link #refreshSessionIdentity()}). + */ + private volatile String launchSessionId; + + /** + * Session ID a {@link SessionMode#NEW} run resolved as the file it created + * for its own submission, or {@code null} before that. The unknown-text + * own-file priority belongs to that file only: afterwards the run's + * conversation is established, and a later recalled prompt must be looked + * up among the run's own and the shared conversations instead of adopting + * an unrelated unclaimed file that merely received a user record after the + * submission. The ID is kept even when the tab adopts a borrowed + * conversation and releases its claim: the own file must stay a candidate + * for the submission that actually went there (see + * {@link #refreshSessionIdentity()}). + */ + private volatile String ownSessionId; + + /** + * The provisional identity this tab adopted and the submission evidence it + * was adopted on, or {@code null}/zero before one was published. A later + * submission from the terminal can replace the prompt evidence without + * resolving the conversation — a {@code /resume} command whose picker the + * user then cancels with Esc, for example — and the ambiguity check of + * {@link #stopAndRename(String)} must still judge the guess on the evidence + * that produced it, or the absence of newer records would read as proof + * that it is this tab's. + */ + private String guessSessionId; + private String guessPromptText; + private long guessPromptAt; + private long guessSince; + + /** + * Session IDs this tab established or guessed as its own before it stopped + * its writer — or before its process exited on its own while the tab stayed + * open — kept eligible for the final post-stop identity lookup of + * {@link #stopAndRename(String)}. Stopping the PTY runs the exit callback, + * which releases this tab's live claims; without the preserved evidence + * this tab's own conversation would then look like another live tab's file + * (or like a file established by another tab) and the lookup could pick an + * unrelated conversation. + * + *

A conversation the tab only borrowed is not listed by the stop-time + * snapshot: it belongs to another live tab, and preserving it would make the + * borrowed file win over the file this tab actually created. The exit-time + * snapshot does carry a borrowed launch conversation: the borrow ends with + * the process, and the tab still has to rename the file it was launched on. + */ + private volatile Set preservedClaims = Set.of(); + + /** + * Time (epoch millis) a prompt was last submitted from the plugin. Used to + * attribute the session file created by a new process to this tab promptly, + * before any sibling tab could guess and claim it. + */ + private volatile long promptSubmittedAt = 0; + + /** + * Text of the last prompt submitted from the plugin or typed in the + * terminal. Together with {@link #promptSubmittedAt} it is the evidence that + * a file written after the prompt contains this tab's own messages — e.g. a + * conversation resumed in-process — rather than a concurrent turn of a + * sibling tab. + */ + private volatile String lastPromptText; + + /** + * True while the current thread writes to the PTY on behalf of the plugin + * (see {@link #writePty(byte[])}), so terminal input can be told apart. + */ + private final ThreadLocal pluginPtyWrite = ThreadLocal.withInitial(() -> false); + + /** + * Time (epoch millis) of the last line submitted in the terminal rather + * than sent by the plugin. Enter can act on the TUI (e.g. choose a session + * in the {@code /resume} picker) without producing a prompt this tab + * recognises, so prompt evidence captured before it no longer describes the + * session on screen. Draft edits do not move it: typing the next prompt + * must not invalidate the evidence of the one already submitted. + */ + private volatile long externalInputAt; + + /** + * Time (epoch millis) of the last conversation change driven by a + * {@code /resume} or {@code /clear} command or by the answer to the + * {@code /resume} picker, whether sent from the prompt panel or typed in + * the terminal. Such a command is not a prompt written to the conversation: + * it switches the conversation the process writes, so prompt evidence + * captured before it no longer proves the shared launch conversation is the + * one on screen. + */ + private volatile long conversationSwitchAt; + + /** + * Anchor time of the scan result that identified a conversation written + * after {@link #conversationSwitchAt}, or {@code 0} when no evidence-based + * result has been published since the switch. + * + *

A submission made after the switch is not proof that its destination + * was followed: the scan may still track the conversation the switch left + * behind, because the destination can be another live tab's file and no + * evidence pass may adopt it. Only a result adopted from a record written + * after the switch shows that the destination is known (see + * {@link #identityRemainsAmbiguous}). + */ + private volatile long postSwitchResolvedAt; + + /** + * Snapshot of the evidence a submitted {@code /resume} replaced, kept until + * the picker confirms a conversation or its admission is cancelled. Opening + * the picker switches nothing, so a cancelled picker must leave the + * post-stop identity lookup with the evidence the command replaced. + * + *

Guarded by {@link #terminalInputLine}. + */ + private boolean pickerSwitchPending; + private long switchBeforePicker; + private long promptBeforePicker; + private long externalBeforePicker; + private String textBeforePicker; + + /** + * Serializes prompt sends from the panel, from the text write through the + * Enter and the publication of its evidence. Two sends pressed in quick + * succession would otherwise interleave: the second reconciliation would + * replace the still-unsubmitted pending record of the first, while the + * TUI line the first Enter submits already contains both texts. + */ + private final Object guiSendLock = new Object(); + + /** + * Delivers panel sends one at a time, in the order they were accepted. + * + *

Serializing the sends on a monitor is not enough: a monitor has no + * first-come-first-served order, so with two sends already waiting the JVM + * may hand it to either of them, and the TUI then receives the prompts in + * an order the user never typed them in — a slash command and the + * instruction it was meant to precede can run swapped. + */ + private java.util.concurrent.ExecutorService guiSendQueue; + + /** Guards the creation and the shutdown of {@link #guiSendQueue}. */ + private final Object guiSendQueueLock = new Object(); + + /** + * Line being typed in the terminal, assembled from the raw PTY input so + * that a prompt submitted from the keyboard is recognised as evidence for + * the conversation on screen (see {@link #onTerminalInput(byte[], long)}). + */ + private final StringBuilder terminalInputLine = new StringBuilder(); + + /** + * Insertion point within {@link #terminalInputLine}, in characters. Cursor + * keys move it so that text inserted after an edit (typing over a typo, + * Delete, Backspace) lands where the user sees it. + */ + private int terminalInputCursor; + + /** + * Escape-sequence parser state: {@code 0} outside a sequence, {@code 1} + * right after {@code ESC}, {@code 2} inside a CSI sequence ({@code ESC [}), + * {@code 3} inside an SS3 sequence ({@code ESC O}). + * + *

A sequence is skipped as a whole: the {@code [} that opens a CSI is a + * parameter byte, not a final one, and treating it as final would let + * cursor keys (e.g. {@code ESC [ D}) leak {@code D} into the typed line. + */ + private int terminalInputEscape; + + /** Parameters of the CSI sequence being parsed, e.g. {@code 200}. */ + private final StringBuilder terminalEscapeParams = new StringBuilder(); + + /** + * True when the {@code ESC} that may still turn out to be a standalone + * cancel was pressed while a choice menu was open. Text typed into a + * picker's search box never reaches a prompt, so when the {@code ESC} is + * confirmed as a live cancellation the recorded line is discarded. + */ + private boolean terminalEscapeOnMenu; + + /** + * True when that Esc cancelled the {@code /resume} picker itself: the + * conversation never switched, so the evidence the resume command replaced + * is restored (see {@link #restorePickerSwitch()}). + * Guarded by {@link #terminalInputLine}. + */ + private boolean terminalEscapeOnPicker; + + /** + * The choice menu the terminal input already completed (a digit selected a + * numbered item, Enter or Right submitted, Esc cancelled) while the model + * still reports it. The screen is polled only every half second, so the + * dismissed menu stays in the model for a while; input arriving in that + * window must not be classified against a menu the TUI no longer shows. + * Compared by identity: every detected menu is a fresh instance. + * + *

Guarded by {@link #terminalInputLine}. + */ + private ChoiceMenuModel terminalCompletedMenu; + + /** + * True when the last Enter confirmed a choice menu selection instead + * of submitting a prompt. Only the {@code /resume} picker changes the + * conversation that way: other menus (permission prompts, the trust-folder + * confirmation, model pickers) answer a question in the current + * conversation. + * + *

Guarded by {@link #terminalInputLine}. + */ + private boolean terminalMenuSelection; + + /** + * The menu the last Enter selected an item from, or {@code null}. Kept so + * the caller can tell a conversation switch (the {@code /resume} picker) + * from an ordinary menu answer. Guarded by {@link #terminalInputLine}. + */ + private ChoiceMenuModel terminalSelectedMenu; + + /** + * True while a bracketed paste ({@code ESC [ 200 ~} … {@code ESC [ 201 ~}) + * is being recorded: its newlines belong to the pasted prompt and must not + * submit the line. + */ + private boolean terminalInputPaste; + + /** True when a pasted CR was recorded and the LF of a CRLF pair must be skipped. */ + private boolean terminalPasteAfterCR; + + /** + * True after Up/Down was pressed on an empty recorded line: the TUI then + * fills its input from its prompt history, with text this parser never + * sees. A following Enter is still a submission, only with unknown text. + */ + private boolean terminalHistoryRecall; + + /** + * True after a live Tab: the TUI completes the input from its own + * suggestions with text this parser never sees, so the recorded line is + * only a prefix of what will be submitted. + */ + private boolean terminalCompletionEdited; + + /** Set when Enter submitted a history-recall or completion-edited line the parser did not record. */ + private boolean terminalRecalledSubmission; + + /** + * True when the terminal received input other than Enter since the + * recorder was last reset, including edits the recorder cannot see in the + * buffer: Backspace on an empty recording, cursor keys, Ctrl+U or a Tab + * completion. Keys typed during the pause between the GUI prompt's text + * and its Enter are part of the line the TUI submits, so they invalidate + * the composed evidence even when they leave the recorded line empty. + */ + private boolean terminalInputSinceGuiSend; + + /** + * True when Enter was pressed in the terminal since the recorder was last + * reset, i.e. while the GUI prompt was still waiting for its own Enter. + * The TUI then submits the line at that Enter, and the panel's later Enter + * only submits an empty line: its evidence belongs to the first + * submission and must not be re-timed. + */ + private boolean terminalEnterSinceGuiSend; + + /** + * Set when terminal input arrived after the terminal Enter that submitted + * a GUI prompt during its pause. The Enter cleared the recorder, so that + * input starts a new line, which the delayed GUI Enter then submits; + * unlike the input before the Enter, its text is exactly what the + * conversation receives. Cleared with the rest of the recorder state. + */ + private boolean terminalDraftAfterEnter; + + /** + * Time of the terminal Enter that submitted a GUI send during its pause, + * or {@code 0} when no such Enter arrived. That Enter is the actual + * submission, so its time — not the prompt-send time — is the evidence + * cutoff. Guarded by {@link #terminalInputLine}. + */ + private long terminalGuiSubmitAt; + + /** + * Whether keys edited the GUI prompt's line before the terminal Enter that + * submitted it during the pause. {@link #terminalInputSinceGuiSend} cannot + * answer that afterwards: a draft typed after that Enter starts a new line + * and also sets it. Captured when the Enter's time is recorded and cleared + * with the rest of the recorder state. + */ + private boolean terminalGuiLineEdited; + + /** + * The choice menu whose cursor the terminal input has moved since the + * screen poll last reported its position, and the option it now sits on. + * + *

The poll is what normally moves {@link ChoiceMenuModel#defaultOptionIndex()}, + * so between polls it names the previous option. A digit activates a + * text-input option and an Up/Down arrow steps through the items, and both + * happen without a poll in between: a digit naming an ordinary option must + * not retire the menu while the cursor is in an editor, and an arrow that + * left one must let the next digit select an item again. Compared by + * identity, so a menu instance published by a newer poll takes over; + * cleared when the menu is completed. Guarded by + * {@link #terminalInputLine}. + */ + private ChoiceMenuModel terminalMenuFocusOwner; + + /** The tracked cursor position in {@link #terminalMenuFocusOwner}. */ + private int terminalMenuFocusIndex = -1; + + /** + * True between a GUI send's prompt write and the Enter that submits it: + * the panel text is already on the TUI's input line. A terminal Enter in + * that window submits the panel text together with what was typed there, + * so the text recorded by the terminal parser is only a part of the + * prompt and must not be published as its evidence. + */ + private boolean guiSendAwaitingEnter; + + /** + * Set when a choice menu answered from the terminal consumed the line a + * GUI send had written: that text was the menu's search filter or answer, + * not a prompt, so the panel's delayed Enter must not publish it as + * prompt evidence. Cleared with the rest of the recorder state. + */ + private boolean terminalMenuConsumedGuiSend; + + /** + * Set when the panel text itself answered the choice menu it was written + * into: a bare digit selects a numbered menu's item on arrival (see + * {@link #writePtyAnswer(String)}), so the menu is gone before the delayed + * Enter and the TUI's input line is empty again. A terminal Enter during + * the pause then submits a line the recorder holds in full, and a draft + * typed there is submitted by the delayed Enter. Cleared with the rest of + * the recorder state. + */ + private boolean terminalGuiTextAnsweredMenu; + + /** + * Prompt of a GUI send whose Enter has not been written yet. It must not + * become own-session evidence before the actual submission: until then the + * process has written nothing, and an identity scan running in the pause + * could adopt another conversation's file (by matching its text, or by + * time for a partial line) although nothing was submitted. + * Published by {@link #completeGuiSend()}. Guarded by + * {@link #terminalInputLine}. + */ + private String pendingGuiPromptText; + + /** Submission time of {@link #pendingGuiPromptText}. */ + private long pendingGuiPromptAt; + + /** Whether {@link #pendingGuiPromptText} is the whole submitted line. */ + private boolean pendingGuiPromptExact; + + /** Whether a GUI send is waiting for its Enter to establish evidence. */ + private boolean pendingGuiPromptSet; + + /** + * Whether {@link #pendingGuiPromptText} is a TUI command that switches the + * conversation ({@code /resume}, {@code /clear}). The switch is evidence + * only once the command is actually submitted: the line can still be edited + * away during the pause (Ctrl+U and a new prompt), and a switch timestamp + * left behind would let the identity scan adopt the newer conversation the + * command never switched to. + * Guarded by {@link #terminalInputLine}. + */ + private boolean pendingGuiPromptSwitch; + + /** + * Whether the command of {@link #pendingGuiPromptSwitch} is a + * {@code /resume}, i.e. one whose switch waits for the picker admission + * and can still be cancelled. {@code /clear} switches at once. + * Guarded by {@link #terminalInputLine}. + */ + private boolean pendingGuiPromptResume; + + /** Screen-poll ticks since the last background session-identity scan. */ + private int identityPollTicks = 0; + /** * Called when a hang is detected: receives (command, errorMessage). * Invoked on the EDT after the process is stopped. @@ -181,6 +711,155 @@ void simulateProcessStart(long startedAt, boolean outputReceived) { firstOutputReceived = outputReceived; } + /** Package-private for tests: sets the session identity captured at launch. */ + void simulateActiveSession(java.nio.file.Path configDir, String sessionId, + Set preStartIds) { + activeConfigDir = configDir; + activeSessionId = sessionId; + preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); + launchIdentityKnown = sessionId != null; + launchSessionId = sessionId; + ownSessionId = null; + } + + /** Package-private for tests: one tick of the periodic identity refresh. */ + void simulateIdentityTick() { + refreshSessionIdentityIfDue(); + } + + /** Package-private for tests: records a prompt submitted from the plugin. */ + void simulatePromptSubmittedAt(long millis) { + promptSubmittedAt = millis; + } + + /** Package-private for tests: records the text of the last submitted prompt. */ + void simulatePromptText(String text) { + lastPromptText = text; + } + + /** Package-private for tests: the recorded text of the last submission. */ + String simulateLastPromptText() { + return lastPromptText; + } + + /** Package-private for tests: the recorded time of the last submission. */ + long simulatePromptSubmittedAtValue() { + return promptSubmittedAt; + } + + /** Package-private for tests: the terminal-input cutoff of the prompt evidence. */ + long simulateExternalInputAtValue() { + return externalInputAt; + } + + /** Package-private for tests: the prompt-evidence part of {@link #sendPrompt(String)}. */ + void simulateSentPrompt(String text, long sentAt) { + rememberSentPrompt(text, sentAt); + } + + /** Package-private for tests: the choice-menu bookkeeping of {@link #writePtyAnswer(String)}. */ + void simulateGuiChoiceAnswer(String answer) { + rememberChoiceMenuAnswer(answer); + } + + /** + * Package-private for tests: the recorder and evidence part of + * {@link #sendPrompt(String)}. + * + * @return the line the conversation receives + */ + String simulateGuiSend(String text, long sentAt) { + return reconcileGuiSend(text, sentAt); + } + + /** + * Package-private for tests: the reconciliation and the initial text write + * of {@link #sendPrompt(String)}, without the pause and Enter that + * {@link #completeGuiSend()} adds. + */ + void simulateGuiSendWithText(String text, long sentAt) throws IOException { + writeGuiPrompt(text, () -> sentAt, currentProcessGeneration()); + } + + /** + * {@link #simulateGuiSendWithText(String, long)} for a send accepted for + * {@code generation}, so a test can deliver it after a restart replaced the + * connector. + */ + void simulateGuiSendWithText(String text, long sentAt, long generation) throws IOException { + writeGuiPrompt(text, () -> sentAt, generation); + } + + /** + * Package-private for tests: the body of {@link #sendPrompt(String)}'s + * sender thread — the serialized text write, pause and Enter — with a + * controllable clock and the real-time sleep replaced by {@code pause}. + */ + void simulateSendPrompt(String text, java.util.function.LongSupplier writeClock, + java.util.function.LongSupplier enterClock, Runnable pause) + throws IOException { + long sendGeneration = currentProcessGeneration(); + synchronized (guiSendLock) { + if (!isProcessGenerationCurrent(sendGeneration)) { + return; + } + writeGuiPrompt(text, writeClock, sendGeneration); + pause.run(); + if (!isProcessGenerationCurrent(sendGeneration)) { + return; + } + completeGuiSend(enterClock, sendGeneration); + } + } + + /** + * Package-private for tests: submits a send through the ordered queue, as + * {@link #sendPrompt(String)} does, and returns the queued task. + */ + java.util.concurrent.Future simulateQueueGuiSend(String text) { + return queueGuiSend(text, currentProcessGeneration()); + } + + /** + * Package-private for tests: submits a send through the ordered queue with + * controllable text-write and submission times, so a test can release it + * during a stop and check what its late Enter would have recorded. + */ + java.util.concurrent.Future simulateQueueGuiSend(String text, + java.util.function.LongSupplier writeClock, + java.util.function.LongSupplier enterClock) { + return queueGuiSend(text, currentProcessGeneration(), writeClock, enterClock); + } + + /** Package-private for tests: the number of identity scans started. */ + long simulateScanSequence() { + synchronized (processOwnershipLock) { + return scanSequence; + } + } + + /** + * Package-private for tests: marks this controller as a live session, so + * that other controllers treat {@code sessionId} as owned by another tab. + */ + void simulateLiveSession(java.nio.file.Path configDir, String sessionId, + Set preStartIds) { + simulateActiveSession(configDir, sessionId, preStartIds); + workingDir = model.getWorkingDirectory(); + LIVE_SESSIONS.add(this); + claimSession(workingDir, configDir, sessionId); + } + + /** + * Package-private for tests: adds a claim on {@code sessionId} to a live + * session that has already been started, i.e. the bookkeeping a scan + * publishes after it resolved the conversation. + */ + void simulateClaimSession(java.nio.file.Path configDir, String sessionId) { + claimSession(workingDir != null ? workingDir : model.getWorkingDirectory(), + configDir, sessionId); + } + /** Package-private for tests: injects a stripped PTY line into the rolling buffer. */ void simulatePtyLine(String line) { synchronized (recentPtyLines) { @@ -246,28 +925,177 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr public void startProcess(java.io.File dir, String profileName, String extraCliArgs, SessionMode mode, String resumeSessionId, JediTermWidget widget) throws IOException { - String tag = "[" + dir.getName() + "] "; - workingDir = dir; - claudeProcess = new ClaudeProcess(); - ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); - customModelIds = profile != null ? new ArrayList<>(profile.getComboModelIds()) : List.of(); - PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, - extraCliArgs != null ? extraCliArgs : "", - mode != null ? mode : SessionMode.NEW, - resumeSessionId); - - processStartedAt = System.currentTimeMillis(); - firstOutputReceived = false; + startProcess(dir, profileName, extraCliArgs, mode, resumeSessionId, + prepareLaunchIdentity(dir, profileName, mode, resumeSessionId), widget); + } - connector = new PtyTtyConnector(process); - connector.setSessionTag(tag); - screenContentDetector.setSessionTag(tag); + /** + * Launch-time session identity, captured before the PTY starts. + * + * @param configDir config dir the process will write to + * @param preStartSessionIds session IDs that already existed at launch + * @param sessionId session ID the process will use, or {@code null} + * when it is only known after the first write + */ + public record LaunchIdentity(java.nio.file.Path configDir, + Set preStartSessionIds, + String sessionId) { } - connector.setLineListener(line -> { - firstOutputReceived = true; - LOG.fine(tag + "[PTY line] " + line); - synchronized (recentPtyLines) { - recentPtyLines.addLast(line); + /** + * Computes the launch-time session identity by scanning the session store. + * + *

The scan can take hundreds of milliseconds on projects with a large + * history, so callers on the EDT should run it on a worker thread and pass + * the result to + * {@link #startProcess(java.io.File, String, String, SessionMode, String, LaunchIdentity, JediTermWidget)}. + * The result must describe the store before the process starts. + * + * @param dir working directory + * @param profileName profile name, or {@code null}/{@code ""} for Default + * @param mode session start mode + * @param resumeSessionId session ID to resume (only used for RESUME_SPECIFIC) + * @return the identity the started process will use + */ + public static LaunchIdentity prepareLaunchIdentity(java.io.File dir, String profileName, + SessionMode mode, + String resumeSessionId) { + ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); + return prepareLaunchIdentity(dir, + ClaudeProcess.resolveConfigDir(profile, ClaudeCodePreferences.getProfilesDir()), + mode, resumeSessionId); + } + + /** Package-private for tests: captures the identity against an explicit config dir. */ + static LaunchIdentity prepareLaunchIdentity(java.io.File dir, + java.nio.file.Path configDir, + SessionMode mode, + String resumeSessionId) { + SessionMode effectiveMode = mode != null ? mode : SessionMode.NEW; + Set preStartSessionIds = + ClaudeSessionStore.listSessionIds(dir.toPath(), configDir); + String sessionId = null; + if (effectiveMode == SessionMode.RESUME_SPECIFIC + && resumeSessionId != null && !resumeSessionId.isBlank()) { + sessionId = resumeSessionId; + } else if (effectiveMode == SessionMode.CONTINUE_LAST) { + sessionId = ClaudeSessionStore.findLatestSessionId(dir.toPath(), configDir); + } + return new LaunchIdentity(configDir, preStartSessionIds, sessionId); + } + + /** + * Makes the launch identity describe the store the process will actually + * write. + * + *

The identity is prepared on a worker thread, while the spawn re-reads + * the profile. If the profile's storage directory or + * {@code CLAUDE_CONFIG_DIR} override changed in between, the prepared + * snapshot lists the old store's sessions: every file in the new store + * would look new, and a conversation written there within the file-time + * slack could be adopted as this tab's own. The snapshot is then + * recaptured against the spawn store before the process starts writing. + * + * @param dir working directory + * @param mode session start mode + * @param resumeSessionId session ID to resume (only used for RESUME_SPECIFIC) + * @param prepared identity prepared earlier; may be {@code null} + * @param spawnConfigDir config dir the process will use, or {@code null} + * when it cannot be resolved + * @return the prepared identity when it already describes + * {@code spawnConfigDir}, otherwise a fresh snapshot of that store + */ + static LaunchIdentity reconcileLaunchIdentity(java.io.File dir, SessionMode mode, + String resumeSessionId, + LaunchIdentity prepared, + java.nio.file.Path spawnConfigDir) { + if (spawnConfigDir == null) return prepared; + if (prepared != null && prepared.configDir() != null + && prepared.configDir().toAbsolutePath().normalize() + .equals(spawnConfigDir.toAbsolutePath().normalize())) { + return prepared; + } + return prepareLaunchIdentity(dir, spawnConfigDir, mode, resumeSessionId); + } + + /** + * Starts the PTY process using a session identity prepared earlier with + * {@link #prepareLaunchIdentity(java.io.File, String, SessionMode, String)}. + * + * @param dir working directory + * @param profileName profile name, or {@code null}/{@code ""} for Default + * @param extraCliArgs extra CLI arguments (may be blank) + * @param mode session start mode + * @param resumeSessionId session ID to resume (only used for RESUME_SPECIFIC) + * @param identity launch-time identity, captured before the process starts + * @param widget terminal widget + * @throws IOException if the PTY process cannot be started + */ + public void startProcess(java.io.File dir, String profileName, String extraCliArgs, + SessionMode mode, String resumeSessionId, + LaunchIdentity identity, JediTermWidget widget) throws IOException { + String tag = "[" + dir.getName() + "] "; + workingDir = dir; + long generation; + synchronized (processOwnershipLock) { + claudeProcess = new ClaudeProcess(); + generation = ++processGeneration; + } + ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); + customModelIds = profile != null ? new ArrayList<>(profile.getComboModelIds()) : List.of(); + + // The session identity was captured before launch so that stopping/renaming + // later targets the session this process will actually use (see stopAndRename). + // The profile is re-read above for the spawn, so a profile whose storage + // moved in Options while the background preparation scanned needs the + // snapshot recaptured in the store the process is about to write. + SessionMode effectiveMode = mode != null ? mode : SessionMode.NEW; + LaunchIdentity launch = reconcileLaunchIdentity(dir, effectiveMode, resumeSessionId, + identity, + ClaudeProcess.resolveConfigDir(profile, ClaudeCodePreferences.getProfilesDir())); + preStartSessionIds = launch.preStartSessionIds(); + activeSessionId = launch.sessionId(); + launchIdentityKnown = activeSessionId != null; + launchSessionId = activeSessionId; + ownSessionId = null; + postSwitchResolvedAt = 0L; + pickerSwitchPending = false; + switchBeforePicker = 0L; + promptBeforePicker = 0L; + externalBeforePicker = 0L; + textBeforePicker = null; + + PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, + extraCliArgs != null ? extraCliArgs : "", + effectiveMode, resumeSessionId); + + activeConfigDir = claudeProcess.getConfigDir(); + if (activeConfigDir == null) { + activeConfigDir = launch.configDir(); + } + processStartedAt = System.currentTimeMillis(); + firstOutputReceived = false; + + LIVE_SESSIONS.add(this); + claimSession(dir, activeConfigDir, activeSessionId); + + connector = new PtyTtyConnector(process); + connector.setSessionTag(tag); + // Terminal input is forwarded and recorded under the same lock the + // plugin holds while it writes the GUI prompt's Enter, so a write + // cannot reach the child before that Enter while being recorded after + // the recorder was reset for it. + connector.setInputLock(terminalInputLine); + connector.setInputListener((bytes, sentAt) -> { + if (Boolean.TRUE.equals(pluginPtyWrite.get())) return; + onTerminalInput(bytes, sentAt); + }); + screenContentDetector.setSessionTag(tag); + + connector.setLineListener(line -> { + firstOutputReceived = true; + LOG.fine(tag + "[PTY line] " + line); + synchronized (recentPtyLines) { + recentPtyLines.addLast(line); if (recentPtyLines.size() > PTY_LINE_BUFFER_SIZE) { recentPtyLines.removeFirst(); } @@ -297,12 +1125,16 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr model.setWorkingDirectory(dir); model.loadPersistedHistory(); - // Waiter thread: notifies when the process exits + // Waiter thread: releases the session claims as soon as the process exits, + // even when it exits from inside the terminal without an explicit stop. + long exitedGeneration = generation; Thread waiter = new Thread(() -> { try { process.waitFor(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); + } finally { + onProcessExited(exitedGeneration); } }, "claude-waiter"); waiter.setDaemon(true); @@ -319,17 +1151,58 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr * *

Does not close the terminal widget — the View handles that. */ - /** Returns the last command attempted to start the process. */ - public String getLastAttemptedCommand() { return claudeProcess.getLastCommand(); } + /** Returns the last command attempted to start the process, or {@code null} if none was attempted. */ + public String getLastAttemptedCommand() { + ClaudeProcess p = claudeProcess; + return p != null ? p.getLastCommand() : null; + } /** * Returns the UUID of the active OpenAI-compatible proxy session, or * {@code null} if the current session isn't using the OpenAI-compatible or * ChatGPT Subscription connection type. See {@link io.github.nbplugins.claudecodegui.process.ClaudeProcess#getOpenAIProxyUuid()}. + * + *

Returns {@code null} when no process exists (before a launch completes + * or after a stop), so UI callbacks such as the context menu can query it + * at any time. */ - public String getOpenAIProxyUuid() { return claudeProcess.getOpenAIProxyUuid(); } + public String getOpenAIProxyUuid() { + ClaudeProcess p = claudeProcess; + return p != null ? p.getOpenAIProxyUuid() : null; + } - public void stopProcess() { + /** + * Returns whether a prompt can be written to the session right now. + * + *

{@link #sendPrompt(String)} silently drops the text when no PTY + * connector exists yet (the launch is prepared in the background), so the + * input panel asks this before clearing the field. + */ + public boolean canSendPrompt() { return connector != null; } + + /** + * Kills the PTY child process, if one is running. + * + *

Closes the child's end of the PTY, which is the only thing that can + * release a writer blocked in the native layer: a hung or non-reading + * process never drains the pipe, and interrupting the writing thread does + * not interrupt a native {@code OutputStream.write()}. + */ + private void killPtyProcess() { + ClaudeProcess processToStop = claudeProcess; + if (processToStop != null) { + processToStop.killOnly(); + } + Runnable hook = ptyKillHook; + if (hook != null) hook.run(); + } + + /** + * Kills the PTY process and detaches the terminal, but keeps the identity + * inputs so the conversation that was on screen can still be resolved + * after the writer is gone. + */ + void stopWriter() { if (statusTimer != null) { statusTimer.stop(); statusTimer = null; @@ -338,11 +1211,52 @@ public void stopProcess() { if (connector != null) { connector.setLineListener(null); } - if (claudeProcess != null) { - claudeProcess.stop(); - } + killPtyProcess(); connector = null; - claudeProcess = null; + } + + public void stopProcess() { + shutdownGuiSendQueue(); + stopWriter(); + ClaudeProcess processToStop = claudeProcess; + if (processToStop != null) { + processToStop.stop(); + } + // The generation bump, the identity inputs and the ownership bookkeeping + // are reset under one lock: a scan queued between the bump and the reset + // would otherwise capture the new generation together with the old run's + // inputs and could republish (and re-claim) the stopped identity. + synchronized (processOwnershipLock) { + claudeProcess = null; + processGeneration++; + processStartedAt = 0; + firstOutputReceived = false; + activeConfigDir = null; + activeSessionId = null; + preStartSessionIds = Set.of(); + launchIdentityKnown = false; + launchSessionId = null; + ownSessionId = null; + guessSessionId = null; + guessPromptText = null; + guessPromptAt = 0L; + guessSince = 0L; + promptSubmittedAt = 0; + lastPromptText = null; + externalInputAt = 0; + conversationSwitchAt = 0; + postSwitchResolvedAt = 0L; + pickerSwitchPending = false; + switchBeforePicker = 0L; + promptBeforePicker = 0L; + externalBeforePicker = 0L; + textBeforePicker = null; + resetTerminalInputRecording(); + identityPollTicks = 0; + LIVE_SESSIONS.remove(this); + releaseAllClaims(); + preservedClaims = Set.of(); + } terminalWidget = null; modelComboPopulated = false; modelDiscoveryInProgress = false; @@ -350,8 +1264,6 @@ public void stopProcess() { customModelIds = List.of(); standardModelCount = 0; synchronized (recentPtyLines) { recentPtyLines.clear(); } - processStartedAt = 0; - firstOutputReceived = false; model.setModelList(List.of(), -1); clearActiveChoiceMenu(); @@ -360,63 +1272,2580 @@ public void stopProcess() { } /** - * Stops the PTY process and, if {@code sessionName} is non-blank, writes a - * {@code custom-title} entry to the most recent session JSONL file. + * Stops the PTY process and, if {@code sessionName} is non-blank, appends a + * {@code custom-title} entry to the JSONL file of the session that was running. + * + *

The target session and config dir are tracked from launch, so the rename + * works for named profiles (isolated config dir), profiles that map to + * {@code ~/.claude}, several sessions sharing the same project, and + * conversations switched with {@code /resume} inside the running process. + * + * @param sessionName name to persist, or {@code null}/blank to skip renaming + */ + public void stopAndRename(String sessionName) { + boolean rename = sessionName != null && !sessionName.isBlank(); + java.io.File dir = model.getWorkingDirectory(); + java.nio.file.Path configDir = activeConfigDir; + // Resolving the identity enumerates the session store, which can take + // long on projects with thousands of conversations. The prompt panel's + // "New session" action stops with a blank name; no rename will be + // attempted, so that work is skipped before the PTY is stopped. + String sessionId = null; + if (rename) { + // A panel send still queued, or paused between its text and its + // Enter, would complete after the writer is stopped: its Enter is + // dropped, yet it could replace the prompt evidence the lookup + // below matches. Cancel the queue first and wait for a send that is + // already completing, so the snapshot is taken after it — or after + // its cancellation — instead of racing with it. + // A sender may be blocked in a native write to a hung or + // non-reading process, and interrupting its thread does not + // release that write. Kill the child first, which closes its end + // of the PTY and unblocks the write, or the wait below (on the + // EDT) could last forever. + killPtyProcess(); + shutdownGuiSendQueue(); + // An Esc that cancelled the /resume picker and was never followed by + // another keystroke is still pending: no later write consumed it, yet + // the picker is gone and the conversation never switched. Consume it + // before the lookup, or the evidence it should restore stays replaced + // and the switch reads as unresolved. + synchronized (terminalInputLine) { + if (terminalInputEscape == 1 && terminalEscapeOnMenu + && terminalEscapeOnPicker) { + restorePickerSwitch(); + discardPickerSearchText(); + terminalInputEscape = 0; + } + } + synchronized (guiSendLock) { + sessionId = resolveActiveSessionId(); + // The writer may still be finishing a turn, and the user may have + // switched the conversation in the TUI with the new file not yet + // written. Stop it first, then resolve once more with the same + // evidence rules: whatever it wrote last is on disk now. The exit + // callback releases this tab's claims, so the conversations it + // established or guessed as its own are carried over explicitly. + // A borrowed conversation is another live tab's file and stays + // eligible to that tab instead. A process that quit on its own + // before this call already released the claims and left the + // conversations it held in preservedClaims: they count the same. + // The exit waiter moves the claims between those two sets under + // the ownership lock, so both are read under it: reading them + // apart could take the old preserved set and the already + // cleared live set, hiding the conversation this tab held. + Runnable hook = claimSnapshotHook; + if (hook != null) hook.run(); + Set held; + synchronized (processOwnershipLock) { + held = new HashSet<>(preservedClaims); + held.addAll(claimedSessionIds(dir, configDir)); + } + stopWriter(); + sessionId = resolveIdentityWithPreservedClaims(held); + } + } + // Two NEW tabs that submitted identical text leave the identity + // unresolved: each tracks the file whose prompt matched first, and the + // other tab writes a file holding the same text. The name entered in the + // dialog must not be written into the sibling's conversation. + boolean ambiguous = rename + && identityRemainsAmbiguous(dir, configDir, sessionId); + stopProcess(); + + if (!rename) return; + if (ambiguous) { + LOG.warning("Skipped renaming session " + sessionId + " in " + configDir + + ": another file holds this prompt and the identity cannot be" + + " told apart"); + return; + } + if (dir == null || configDir == null || sessionId == null) { + LOG.warning("Could not rename session: no active session file found in " + + ClaudeSessionStore.sessionDirForLog( + dir != null ? dir.toPath() : null, configDir) + + " (dir=" + dir + ", configDir=" + configDir + ")"); + return; + } + String name = sessionName.trim(); + try { + SavedSession current = ClaudeSessionStore.findSession(dir.toPath(), configDir, sessionId); + if (current != null && name.equals(current.customTitle())) { + LOG.fine("Session " + sessionId + " already titled '" + name + "'; nothing to do"); + return; + } + ClaudeSessionStore.renameSession(dir.toPath(), configDir, sessionId, name); + LOG.fine("Renamed session " + sessionId + " to '" + name + "' in " + configDir); + } catch (Exception e) { + LOG.warning("Could not rename session " + sessionId + " in " + configDir + ": " + e.getMessage()); + } + } + + /** + * Returns whether {@code sessionId} is a conversation this tab cannot prove + * it is the writer of: this tab has not established it at launch, and the + * store also holds a file with this run's prompt that no other live tab + * claims. + * + *

Two NEW tabs that submitted identical text leave exactly this state: + * the prompt matched in the sibling's file first, so this tab tracks it + * provisionally, and this tab's own file — the same text, another ID — + * appears later without displacing it. A matching prompt does not establish + * ownership here, so neither file may be renamed as this tab's. + * + *

The sibling need not still be running: its exit releases its claim but + * does not turn this tab's earlier guess into ownership, so the ambiguity is + * judged from the store rather than from the live claim registry. + * + *

Called before {@link #stopProcess()}, while this run's prompt evidence + * still describes the running session. + */ + private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + if (sessionId == null) return false; + if (conversationSwitchAt > 0 && postSwitchResolvedAt < conversationSwitchAt) { + // No scan has identified a conversation written since the switch. + // The identity tracked here is then the conversation the switch + // left behind — whether it was established at launch or resolved + // by evidence before the switch: a picker entry may be a + // conversation another live tab writes, which no evidence pass can + // adopt, so the entered name cannot be attributed to that file. + // A prompt submitted after the switch does not change that: it was + // also written into this tab's launch conversation when the switch + // never took effect, and a submission timestamp alone cannot tell + // the two apart. + return true; + } + if (isLaunchIdentity(sessionId)) return false; + SessionKey key = keyFor(dir, configDir, sessionId); + SessionClaims claims = key != null ? SESSION_CLAIMS.get(key) : null; + if (claims != null && claims.owners.contains(this)) return false; + String promptText = lastPromptText; + long promptAt = promptSubmittedAt; + // A file holding the same prompt that no other live tab established + // could equally be the one this run wrote: the evidence cannot tell + // them apart. A merely tentative claim is another guess, not ownership: + // two NEW tabs can each adopt a different file of the same prompt pair, + // and excluding the other tab's guess would hide the very file that + // matches this tab's, letting the stop rename the wrong conversation. + // Only a file touched during this run can compete; one that merely + // quotes the same text from an earlier session cannot. Once the user has + // typed in the terminal after the submission, the conversation the tab + // was on before that input is the one it left behind, not a competitor: + // only files written after the input are candidates. + long since = Math.max(processStartedAt, promptAt); + if (externalInputAt > since) since = externalInputAt; + synchronized (processOwnershipLock) { + if (sessionId.equals(guessSessionId) && guessPromptAt > 0) { + // This identity is the provisional guess that evidence adopted. + // A later command from the terminal can lift the prompt + // evidence without resolving another conversation — a /resume + // whose picker was cancelled with Esc — which would move the + // cutoff past every file and read as proof of ownership. The + // competition is judged on the evidence the guess came from. + promptText = guessPromptText; + promptAt = guessPromptAt; + since = guessSince; + } + } + if (promptAt <= 0) return false; + // A file another live tab launched on is that tab's conversation, but + // the launch does not prove it recorded this submission: the tab may + // have resumed the very file this run wrote, and only files written + // since the submission are considered. A later resume of a competing + // file therefore leaves the earlier guess just as unresolved as + // before, so it must still compete; excluding it would authorize a + // rename the evidence cannot support. The tracked file is the + // candidate itself and stays out of the search. + Set shared = new HashSet<>(); + shared.add(sessionId); + if (launchIdentityKnown && launchSessionId != null) { + // The launch conversation is this tab's own by construction, not + // another tab's guess: after a switch it may receive a record of + // its own without being the file this submission went to. + shared.add(launchSessionId); + } + return hasCompetingSession(dir, configDir, since, promptAt, promptText, shared); + } + + /** + * Returns whether the store holds a conversation other than those in + * {@code exclude} that carries this submission: the same text when it is + * known, or a user record of its own when it is not. + * + *

The lookup repeats the resolver's coarse-mtime relaxation: a file + * written right after the submission can round below it and still be the + * one this run wrote, so the second pass starts + * {@link #SESSION_MTIME_GRANULARITY_MS} earlier. The user record's own + * timestamp stays strict either way. + */ + private boolean hasCompetingSession(java.io.File dir, + java.nio.file.Path configDir, + long since, long promptAt, + String promptText, Set exclude) { + boolean unknownText = promptText == null || promptText.isBlank(); + boolean relaxed = since > SESSION_MTIME_GRANULARITY_MS; + long relaxedSince = since - SESSION_MTIME_GRANULARITY_MS; + if (unknownText) { + // The submitted text is unknown (recalled from the TUI history or + // completed by Tab), so no file can be matched by its content: a + // file holding a user record of its own since the submission could + // equally be the one this run wrote. + if (newestSessionWithUserMessageSince(dir, configDir, since, promptAt, exclude) + != null) { + return true; + } + return relaxed && newestSessionWithUserMessageSince(dir, configDir, + relaxedSince, promptAt, exclude) != null; + } + if (findPromptBearingSession(dir, configDir, since, exclude, null, + promptAt, promptText) != null) { + return true; + } + return relaxed && findPromptBearingSession(dir, configDir, relaxedSince, exclude, + null, promptAt, promptText) != null; + } + + /** + * Returns the ID of the session currently being displayed, resolving the + * file created by this run for {@link SessionMode#NEW} sessions. + * + *

The identity is not fixed at launch: Claude can switch conversations + * inside the running process (its own {@code /resume} picker), in which case + * the file written most recently belongs to the new session. Sessions + * claimed by other live tabs are never taken over (see + * {@link #refreshSessionIdentity()}). + * + * @return session ID, or {@code null} if it cannot be determined + */ + public String resolveActiveSessionId() { + refreshSessionIdentity(); + return activeSessionId; + } + + /** + * Resolves the identity of a tab whose writer has just been stopped, while + * keeping the conversations it held before the stop eligible. + * + *

The post-stop lookup must use the pre-stop ownership evidence: once + * the exit callback has released this tab's claims, a conversation the tab + * established would otherwise look like another live tab's file, and the + * lookup could fall back to an unrelated conversation that merely received + * a user record after the submission. + * + * @param heldIds session IDs this tab held before the writer was stopped + * @return the resolved session ID, or {@code null} + */ + String resolveIdentityWithPreservedClaims(Set heldIds) { + Set previous = preservedClaims; + preservedClaims = heldIds != null ? Set.copyOf(heldIds) : Set.of(); + try { + return resolveActiveSessionId(); + } finally { + preservedClaims = previous; + } + } + + /** + * Re-resolves the session the running process is writing and keeps the + * cross-tab claim registry in sync: when Claude switches conversations + * (in-process {@code /resume}), the claim on the previous session is + * released so a sibling tab can take it, and the new one is claimed. + * + *

The identity is the newest session file written since the launch (or + * since the last prompt submitted from the plugin) that is not owned by + * another live tab. When no such file exists yet — e.g. a continue/resume + * launch that has not written anything — the identity captured at launch is + * kept. + * + *

Safe to call from any thread: the parameters are captured and the + * result is published under {@link #processOwnershipLock}, and a scan that + * started before a process restart is discarded instead of overwriting the + * new run's identity. The filesystem lookup itself runs outside the lock. + * + * @return {@code true} if an identity is known + */ + boolean refreshSessionIdentity() { + long generation; + long scanId; + java.io.File dir; + java.nio.file.Path configDir; + long since; + long promptAt; + long externalAt; + long switchAt; + String promptText; + String activeId; + Set exclude = new HashSet<>(); + Set claimed = new HashSet<>(); + Set otherLiveClaims = new HashSet<>(); + Set otherOwned = new HashSet<>(); + synchronized (processOwnershipLock) { + generation = processGeneration; + scanId = ++scanSequence; + dir = workingDir != null ? workingDir : model.getWorkingDirectory(); + configDir = activeConfigDir; + since = Math.max(processStartedAt, promptSubmittedAt); + promptAt = promptSubmittedAt; + externalAt = externalInputAt; + switchAt = conversationSwitchAt; + promptText = lastPromptText; + activeId = activeSessionId; + if (dir != null && configDir != null && since > 0) { + claimed.addAll(claimedByOthers(dir, configDir)); + otherLiveClaims.addAll(claimedByOtherLiveTabs(dir, configDir)); + otherOwned.addAll(sessionsOwnedByOtherLiveTabs(dir, configDir)); + exclude.addAll(claimed); + if (activeSessionId == null && promptSubmittedAt <= 0) { + // Without a prompt of our own there is nothing that proves a + // write belongs to this tab, so a file that existed before + // the launch stays ineligible: it may have been touched by + // another Claude process. After a prompt, anything written + // since it is ours — including a saved conversation resumed + // from the TUI before this session created a file of its own. + exclude.addAll(preStartSessionIds); + } + } + } + String newest = null; + boolean ownEvidence = false; + // Whether the published identity was picked by submission evidence + // (a matching prompt or user record) rather than by file times alone. + // An mtime-only refresh of the same identity carries no new evidence + // about who wrote it, so it must not replace the evidence the guess + // was adopted on (see publishIdentityScanResult). + boolean evidenceBased = false; + if (dir != null && configDir != null && since > 0) { + // A conversation owned by a live tab (or by this tab) that already + // contains the prompt submitted from here is the strongest evidence + // of an in-process /resume: the process is writing that file. It + // must win over a merely newer unclaimed file — e.g. a third tab + // that wrote its own conversation after the prompt and then exited. + // + // Text typed in the terminal after that prompt may have switched the + // conversation through the TUI, in which case the old prompt is no + // longer evidence of what is on screen: fall back to file times. + // A line submitted from the terminal itself counts as the latest + // prompt (same millisecond), so a resume driven entirely from the + // keyboard is still followed. + // The tab's own evidence excludes every conversation another live + // tab established: such a file is not this tab's own, even when it + // is the identity currently tracked (a false adoption). A merely + // borrowed or provisionally claimed file does not count: a sibling + // following this tab's conversation must not cost this tab the + // session it established. + Set ownExclude = new HashSet<>(exclude); + ownExclude.addAll(otherOwned); + // The file this run established for its own submission stays this + // tab's own when another live tab later resumes it explicitly: that + // tab only borrows the conversation, and excluding it would hide + // the very file this process writes and let an unrelated transcript + // win. A merely provisional guess is not remembered here, so this + // cannot resurrect another tab's file. + if (ownSessionId != null) { + ownExclude.remove(ownSessionId); + } + // The launch conversation is this tab's own even when another live + // tab established it (both resumed the same saved file): a + // temporary adoption of a sibling's conversation must not make the + // launch file ineligible, or the sibling keeps winning the search. + if (launchIdentityKnown && launchSessionId != null) { + ownExclude.remove(launchSessionId); + } + if (promptAt > 0 && promptAt >= externalAt) { + // First look for the tab's own conversation: a file nobody else + // claims that already holds this prompt. It must win over a + // sibling's file with the same text, because a NEW tab's own + // file appears a moment after the sibling that submitted the + // same prompt — adopting the sibling then would pin this tab to + // another tab's conversation. + // A session another live tab established is not this tab's own, + // even when it is the identity currently tracked (a false + // adoption). A merely borrowed or provisionally claimed file + // does not count: a sibling following this tab's conversation + // must not cost this tab the session it established. + // + // When the tracked file bears this tab's own prompt and no + // other tab established it, the tab keeps it: with two + // unresolved NEW tabs submitting identical text, the evidence + // is indistinguishable, and displacing the current claim would + // merely swap the two conversations. + // + // A launch-known identity (--continue/--resume) is this tab's + // own conversation even when another live tab shares the same + // saved file (both resumed it, so one of them only borrows it): + // it must stay preferred, or an unrelated file carrying the + // same prompt text would be adopted in its place. + // + // That exception belongs to the launch ID only. An identity + // adopted later (a sibling's conversation followed while both + // tabs submitted identical text) is the sibling's, and keeping + // it preferred would pin this tab to it even after its own + // launch conversation holds the prompt and is strictly newer. + // + // The file this run resolved for its own submission is + // preferred over whichever identity is currently tracked: a + // borrowed sibling file that matched the identical prompt + // before this tab's record was flushed must not keep winning + // once this tab's own file holds the prompt. Only a file no + // other live tab established and this tab does not merely + // follow counts: an explicit resume of the same file by + // another tab owns it. + // + // The tracked identity is subject to the same rule only when + // this tab knows the conversation it launched on: two + // unresolved tabs that submitted identical text legitimately + // keep the file one of them owns provisionally, but a + // launch-known tab has its own conversation to prefer over a + // provisional file it merely borrows. + // + // A launch-known tab's tracked non-launch identity is a guess + // made from matching prompt text: the sibling that actually + // writes that file may not have claimed it yet. When the + // launch conversation itself holds the submission and is + // strictly newer, it is the stronger evidence — the + // submission went there, not to the file this tab merely + // followed — so the guess yields. + boolean launchOutranksOwn = launchIdentityKnown && launchSessionId != null + && ownSessionId != null && !ownSessionId.equals(launchSessionId) + && promptText != null && !promptText.isBlank() + && ClaudeSessionStore.sessionFileModified(dir.toPath(), configDir, + launchSessionId) + > ClaudeSessionStore.sessionFileModified(dir.toPath(), configDir, + ownSessionId) + && containsPrompt(dir, configDir, launchSessionId, promptAt, promptText); + boolean isActiveYielding = isSharedGuess(activeId, otherLiveClaims) + || (launchIdentityKnown && launchSessionId != null + && activeId != null && !activeId.equals(launchSessionId) + && (!activeId.equals(ownSessionId) || launchOutranksOwn)); + String ownPreferred = null; + if (ownSessionId != null && !launchOutranksOwn + && !otherOwned.contains(ownSessionId) + && !isSharedGuess(ownSessionId, otherLiveClaims) + && !isBorrowedByThisTab(dir, configDir, ownSessionId)) { + ownPreferred = ownSessionId; + } else if (activeId != null && !isActiveYielding + && (activeId.equals(launchSessionId) + || !otherOwned.contains(activeId))) { + ownPreferred = activeId; + } else if (launchIdentityKnown && launchSessionId != null) { + ownPreferred = launchSessionId; + } + newest = findPromptBearingSession(dir, configDir, since, ownExclude, + ownPreferred, promptAt, promptText); + evidenceBased = newest != null; + if (ownPreferred != null && !ownPreferred.equals(newest) + && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse file times can round this tab's own file below the + // cutoff even though it already holds the prompt, while an + // unrelated conversation records the same common text in + // the next timestamp bucket. The own file is stronger + // evidence, so the slack is applied to it before the + // lookalike match is accepted: otherwise the lookalike is + // published as this run's own file and later scans keep + // preferring it. + String relaxedOwn = promptBearingPreferred(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, ownExclude, + ownPreferred, promptAt, promptText); + if (relaxedOwn != null) { + newest = relaxedOwn; + evidenceBased = true; + } + } + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse file times can round this tab's own file below the + // cutoff even though it already holds the prompt. Its record + // still proves the conversation, so retry with the slack + // before yielding to a sibling's file that merely carries + // the same text. + newest = findPromptBearingSession(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, ownExclude, + ownPreferred, promptAt, promptText); + evidenceBased = newest != null; + } + // A match in a file this tab only follows is not proof that + // this run created it: it is another live tab's conversation, + // matched because both tabs submitted the same text. + boolean ownFileMatch = newest != null + && !isSharedGuess(newest, otherLiveClaims) + && !isBorrowedByThisTab(dir, configDir, newest); + if (newest != null && (externalAt > 0 || switchAt > 0) + && otherLiveClaims.contains(newest)) { + // A conversation another live tab also writes is not proof + // that this tab's submission went there: that tab's own turn + // can submit the same text. When the user has typed in the + // terminal, the TUI may have switched the conversation, and + // a file holding the same text and written strictly later + // — including one no live tab holds yet, e.g. a saved + // conversation resumed with the picker — is then the one on + // screen. Every file written since the submission competes + // by recency instead of the shared match ending the search. + // A switch made with a resume command — sent from the + // prompt panel, typed in the terminal, or answered in the + // picker — is such a switch as well. + // Without either, the launch conversation stays conclusive. + String newer = findPromptBearingSession(dir, configDir, since, + Set.of(), null, promptAt, promptText); + if (newer == null && since > SESSION_MTIME_GRANULARITY_MS) { + newer = findPromptBearingSession(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, Set.of(), null, + promptAt, promptText); + } + if (newer != null && !newer.equals(newest)) { + newest = newer; + evidenceBased = true; + // A conversation written later by someone else is not + // the file this run created for its own submission. + ownFileMatch = false; + } + } + if (ownFileMatch) { + // The own-prompt evidence found this run's conversation: + // a NEW run has created the file it writes to. + ownEvidence = true; + } + if (newest == null) { + // Only then may a conversation another live tab writes be + // adopted: that is an in-process /resume from this terminal. + // Order the candidates newest first: several live files can + // hold the same text, and the one written last is the + // conversation the process is on — an older sibling that + // submitted the same prompt must not win. + Set borrowed = new HashSet<>(claimed); + borrowed.addAll(otherLiveClaims); + if (activeId != null) borrowed.add(activeId); + Set ordered = new LinkedHashSet<>( + ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, 0L, null, borrowed, + borrowed.size())); + // The tracked conversation is preferred here only when it is + // the launch identity. A borrowed identity was itself + // guessed from the same prompt text, so keeping it + // preferred would pin this tab to a sibling's file even + // after the conversation it actually joined holds the + // submission and is strictly newer. + String borrowedPreferred = activeId != null + && activeId.equals(launchSessionId) ? activeId : null; + newest = findPromptBearingSession(dir, configDir, ordered, + borrowedPreferred, promptAt, promptText); + evidenceBased = newest != null; + } + } + if (promptAt > 0 && promptAt >= externalAt + && (promptText == null || promptText.isBlank())) { + // A prompt recalled from the TUI history or completed by Tab: + // its text is unknown, so no file can match it as evidence. The + // user record is what proves a file was written for this + // submission. + // + // The unclaimed-record priority belongs to a run that has not + // created a file of its own yet: it may write its first file + // without any other tab claiming it, while a sibling's record + // submitted around the same time may just be that tab's own + // turn. A tab whose conversation was known at launch had + // nothing new to create, so an unrelated unclaimed human record + // (e.g. an exited third tab's own prompt) must not displace the + // shared conversation it switched into. Once a run has resolved + // its own file, the priority is limited to the conversations + // this tab holds: a later submission cannot be the one creating + // the first file, so an unrelated unclaimed record must yield + // to the shared conversation the run joined. + // + // A launch-known run is treated the same way once it has + // switched to another conversation and resolved it: a later + // recalled submission must keep that conversation eligible, or + // the sibling file it temporarily follows is renamed in its + // place. + // + // Its launch conversation is different: a user record appended + // to that file after the submission suggests the submission + // went there, so the shared search below keeps it eligible. It + // must not be conclusive on its own, though: when another live + // tab launched the same saved file, its own turn also appends a + // human record there, and a TUI resume into a newer conversation + // must still win. + boolean ownRecordMatch = false; + if (newest == null && (!launchIdentityKnown || ownSessionId != null)) { + // A record is not proof of ownership: a sibling's file the + // tab borrowed earlier is now held by this tab too, so the + // claim-based exclusion no longer hides it. Only a file no + // other live tab established may count as this tab's own. + Set ownCandidates = null; + if (ownSessionId != null) { + // The run has already created its own file, so a later + // unknown-text submission cannot be the one that + // creates it. Only this tab's own conversations keep + // the priority: an unrelated unclaimed file (e.g. an + // exited tab's record) must yield to the shared + // conversation the run joined. The resolved own ID is + // listed explicitly: adopting a borrowed conversation + // releases its claim, and the file must still be + // eligible for the submission that went there. + ownCandidates = claimedSessionIds(dir, configDir); + ownCandidates.add(ownSessionId); + // A file another live tab also writes is not proof of + // this tab's submission: that tab's concurrent turn + // appends a human record of its own. It competes with + // the other shared candidates below by recency. + ownCandidates.removeAll(otherLiveClaims); + } + if (ownCandidates == null) { + newest = newestSessionWithUserMessageSince(dir, configDir, since, + promptAt, ownExclude); + evidenceBased = newest != null; + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + newest = newestSessionWithUserMessageSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, + ownExclude); + evidenceBased = newest != null; + } + ownEvidence = newest != null; + ownRecordMatch = newest != null; + if (newest != null && switchAt > 0) { + // The conversation was switched with a resume + // command before this run created a file of its + // own, so the unclaimed record above may be an + // exited tab's own submission rather than this + // run's first file. The switch's destination is + // among the shared conversations; when it holds + // this submission and was written later, it is the + // conversation on screen, and the unclaimed record + // must not be taken as this run's own. + Set shared = new HashSet<>(claimed); + shared.addAll(otherLiveClaims); + if (activeId != null) shared.add(activeId); + if (launchIdentityKnown && launchSessionId != null) { + shared.add(launchSessionId); + } + String switchedTo = newestSharedSessionWrittenSince(dir, + configDir, since, promptAt, shared, exclude); + if (switchedTo == null + && since > SESSION_MTIME_GRANULARITY_MS) { + switchedTo = newestSharedSessionWrittenSince(dir, + configDir, since - SESSION_MTIME_GRANULARITY_MS, + promptAt, shared, exclude); + } + if (switchedTo != null && !switchedTo.equals(newest) + && ClaudeSessionStore.sessionFileModified( + dir.toPath(), configDir, switchedTo) + > ClaudeSessionStore.sessionFileModified( + dir.toPath(), configDir, newest)) { + newest = switchedTo; + evidenceBased = true; + // A conversation another live tab holds is not + // this run's own file: it was resumed into, so + // adopting it must not establish ownership. + ownEvidence = false; + ownRecordMatch = false; + } + } + } else if (!ownCandidates.isEmpty()) { + newest = newestSharedSessionWrittenSince(dir, configDir, since, + promptAt, ownCandidates, exclude); + evidenceBased = newest != null; + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + newest = newestSharedSessionWrittenSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, + ownCandidates, exclude); + evidenceBased = newest != null; + } + ownRecordMatch = newest != null; + } + } + if (newest == null) { + // Only then may a conversation another live tab holds be + // adopted: that is an in-process /resume from this terminal. + // This must run before the file-time fallback: a third tab + // that wrote its own conversation after the submission and + // then exited is unclaimed, and its newer mtime would + // otherwise hide the resumed conversation. + // + // The launch conversation is a candidate too, even when + // another live tab holds it: this tab's submission may have + // gone there. The store's newest-first order then decides + // between it and a conversation the tab resumed into, so a + // shared launch file whose record came from the other live + // tab does not win merely by being the launch file. + Set shared = new HashSet<>(claimed); + shared.addAll(otherLiveClaims); + if (activeId != null) shared.add(activeId); + if (launchIdentityKnown && launchSessionId != null) { + shared.add(launchSessionId); + } + newest = newestSharedSessionWrittenSince(dir, configDir, since, + promptAt, shared, exclude); + evidenceBased = newest != null; + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse file times can round the write below the submission; + // the record's own timestamp still has to match. + newest = newestSharedSessionWrittenSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); + evidenceBased = newest != null; + } + } + if (switchAt > 0 && !ownRecordMatch) { + // The conversation was switched with a resume command — + // sent from the prompt panel, typed in the terminal or + // answered in the picker — and the destination may be a + // saved conversation no live tab holds, which the shared + // pass above cannot see. A matching record in a shared + // file is not conclusive either: a sibling that submitted + // the same recalled text writes one too. A record in a + // conversation nobody holds can only have come from this + // process, so the files another live tab holds are left + // out. + // + // A conversation this pass already established as this + // run's own keeps the submission: the switch was followed, + // and a sibling that merely appends a later record must + // not take the tab over again. + Set unheld = new HashSet<>(exclude); + unheld.addAll(otherLiveClaims); + String switched = newestSessionWithUserMessageSince(dir, configDir, + since, promptAt, unheld); + if (switched == null && since > SESSION_MTIME_GRANULARITY_MS) { + switched = newestSessionWithUserMessageSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, unheld); + } + // Unheld status alone does not prove this process wrote the + // file: a tab that submitted its own prompt and then + // exited leaves one behind. Only a file written strictly + // later than the shared match is the conversation on + // screen; otherwise the shared match stands. + if (switched != null && (newest == null + || ClaudeSessionStore.sessionFileModified(dir.toPath(), + configDir, switched) + > ClaudeSessionStore.sessionFileModified(dir.toPath(), + configDir, newest))) { + newest = switched; + evidenceBased = true; + // No other live tab holds the switched-to conversation, + // so the record in it can only have come from this + // process: it is the file this run resumed into. It + // must be remembered as the run's own, or the next + // unknown-text submission skips the own-candidate pass + // (which requires a resolved own ID) and a sibling's + // newer record displaces it. + ownEvidence = true; + } + } + } + if (newest == null) { + newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, since, exclude); + evidenceBased = false; + } + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse filesystem timestamps can round a file written right + // after the prompt below its cutoff. Retry with a slack so a + // saved conversation resumed in-process (which existed before + // the launch) is still recognised. Without a prompt of our own + // the pre-launch snapshot stays excluded: the extra second must + // not make a file touched by another process eligible. + Set relaxedExclude = exclude; + if (promptAt <= 0) { + relaxedExclude = new HashSet<>(exclude); + relaxedExclude.addAll(preStartSessionIds); + } + newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, + since - SESSION_MTIME_GRANULARITY_MS, relaxedExclude); + evidenceBased = false; + } + } + return publishIdentityScanResult(generation, scanId, dir, configDir, newest, + ownEvidence, promptText, promptAt, Math.max(since, externalAt), + evidenceBased); + } + + /** + * Returns the session among {@code candidates} whose JSONL already contains + * the prompt this tab submitted after {@code promptAt}, or {@code null}. + * + *

The message proves that the running process has joined that + * conversation, so it is preferred over an mtime-only guess: a sibling tab + * finishing its own turn also writes a file after the prompt, but that file + * holds that tab's messages, not this tab's prompt. + * + *

The session this tab already tracks is checked first: two tabs can + * submit the same prompt ({@code continue}), and then the sibling's file + * matches as well — picking whichever comes first in the set would replace + * a correct identity with a borrowed one. + * + * @param preferred session ID to check first; may be {@code null} + */ + /** + * Returns the session among the files written at or after + * {@code mtimeCutoff}, skipping {@code exclude}, whose JSONL already + * contains {@code promptText} since {@code promptAt}, or {@code null}. + * + *

The candidates keep the store's newest-first order: when several + * unclaimed files hold the same text, the first match must be the one + * written most recently (this tab's own file), not an older conversation + * another tab submitted the same prompt to and then left. + * + * @param preferred session ID to check first; may be {@code null} + */ + /** + * Returns {@code preferred} when it is eligible and already holds the + * submitted prompt, or {@code null}. + * + *

The known own conversation is checked with the same eligibility as + * the discovery candidates — not excluded and written at or after the + * cutoff — but independently of their limited list and of any lookalike + * match found there, so a file merely carrying the same common text + * cannot take over its identity. + */ + private String promptBearingPreferred(java.io.File dir, java.nio.file.Path configDir, + long mtimeCutoff, Set exclude, + String preferred, long promptAt, String promptText) { + if (preferred == null || promptText == null || promptText.isBlank()) return null; + if (exclude != null && exclude.contains(preferred)) return null; + if (ClaudeSessionStore.sessionFileModified(dir.toPath(), configDir, preferred) + < mtimeCutoff) { + return null; + } + return containsPrompt(dir, configDir, preferred, promptAt, promptText) + ? preferred : null; + } + + private String findPromptBearingSession(java.io.File dir, java.nio.file.Path configDir, + long mtimeCutoff, Set exclude, + String preferred, long promptAt, String promptText) { + Set candidates = new LinkedHashSet<>( + ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, mtimeCutoff, exclude, + PROMPT_EVIDENCE_CANDIDATES)); + // The known own conversation is checked even when newer unrelated + // files fill the limited discovery list: it is still eligible, and + // dropping it would let a file carrying the same common prompt text + // take over the tab's own identity. Eligibility — not excluded and + // written since the cutoff — is the same as for the listed files. + if (preferred != null && (exclude == null || !exclude.contains(preferred)) + && ClaudeSessionStore.sessionFileModified( + dir.toPath(), configDir, preferred) >= mtimeCutoff) { + candidates.add(preferred); + } + return findPromptBearingSession(dir, configDir, candidates, preferred, promptAt, + promptText); + } + + private String findPromptBearingSession(java.io.File dir, java.nio.file.Path configDir, + Set candidates, String preferred, + long promptAt, String promptText) { + if (candidates.isEmpty() || promptText == null || promptText.isBlank()) return null; + // The preferred session may have been excluded from the candidates + // (e.g. it is claimed by another live tab): it is checked first only + // while it is still eligible. Returning it otherwise would rename that + // tab's conversation instead of the one this run actually holds. + if (preferred != null && candidates.contains(preferred) + && containsPrompt(dir, configDir, preferred, promptAt, promptText)) { + return preferred; + } + for (String sessionId : candidates) { + if (!sessionId.equals(preferred) + && containsPrompt(dir, configDir, sessionId, promptAt, promptText)) { + return sessionId; + } + } + return null; + } + + /** + * Returns the newest session file written at or after {@code mtimeCutoff}, + * skipping {@code exclude}, that holds a user message at or after + * {@code promptCutoff}, or {@code null}. + * + *

Used when the submitted prompt's text is unknown: a user record is + * then the only evidence that a file was written for this submission, and + * unlike {@link #newestSharedSessionWrittenSince} the file does not have to + * belong to another live tab. + */ + private static String newestSessionWithUserMessageSince(java.io.File dir, + java.nio.file.Path configDir, + long mtimeCutoff, + long promptCutoff, + Set exclude) { + for (String id : ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, mtimeCutoff, exclude, PROMPT_EVIDENCE_CANDIDATES)) { + if (ClaudeSessionStore.containsUserMessageSince( + dir.toPath(), configDir, id, promptCutoff)) { + return id; + } + } + return null; + } + + /** + * Returns the newest session ID in {@code shared} whose file was written at + * or after {@code mtimeCutoff} and holds a user message at or after + * {@code promptCutoff}, or {@code null}. + * + *

Used when the submitted prompt's text is unknown (a history recall): + * only a conversation another live tab holds can be the one this process + * switched into, so unrelated newer files must not be adopted. The user + * record is required because the other tab may simply be finishing its own + * turn, which appends assistant output but no user message. + */ + private static String newestSharedSessionWrittenSince(java.io.File dir, + java.nio.file.Path configDir, + long mtimeCutoff, + long promptCutoff, + Set shared, + Set exclude) { + if (shared.isEmpty()) return null; + Set sharedExclude = new HashSet<>(exclude); + sharedExclude.removeAll(shared); + // Only the shared conversations may be considered: newer unrelated + // files must not fill the limited candidate list and hide the + // destination that holds this tab's submission. + for (String id : ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, mtimeCutoff, sharedExclude, shared, + PROMPT_EVIDENCE_CANDIDATES)) { + if (ClaudeSessionStore.containsUserMessageSince( + dir.toPath(), configDir, id, promptCutoff)) { + return id; + } + } + return null; + } + + /** Returns whether {@code sessionId} holds {@code promptText} since {@code promptAt}. */ + private static boolean containsPrompt(java.io.File dir, java.nio.file.Path configDir, + String sessionId, long promptAt, String promptText) { + return ClaudeSessionStore.containsUserPromptSince( + dir.toPath(), configDir, sessionId, promptAt, promptText); + } + + /** + * Publishes a scan result, unless it was superseded: a scan started before a + * process restart describes a store the current run does not use, and a scan + * started before another one that already published must not undo the newer + * result (the synchronous dialog path and the background worker can scan + * concurrently within the same process run). + */ + private boolean publishIdentityScanResult(long generation, long scanId, + java.io.File dir, + java.nio.file.Path configDir, + String newest, boolean ownEvidence, + String promptText, long promptAt, long since, + boolean evidenceBased) { + synchronized (processOwnershipLock) { + if (generation != processGeneration) { + return activeSessionId != null; + } + if (scanId < lastPublishedScanId) { + return activeSessionId != null; + } + lastPublishedScanId = scanId; + if (newest != null && evidenceBased && conversationSwitchAt > 0 + && since >= conversationSwitchAt) { + // The result was adopted from a record written after the switch: + // the conversation on screen is known, so a later submission may + // authorize the rename. An mtime-only result says nothing about + // the destination and must not: a later prompt would otherwise + // read as proof that the switch was followed (see + // identityRemainsAmbiguous). + postSwitchResolvedAt = since; + } + if (ownEvidence && newest != null) { + ownSessionId = newest; + } + if (newest != null && (evidenceBased || !newest.equals(guessSessionId))) { + // Remember the submission the identity was adopted on: terminal + // input can later lift the prompt evidence without resolving + // another conversation, and the stop-time ambiguity check must + // still use the adoption's evidence then. A file-time-only + // refresh of the same identity carries no new submission: only + // an assistant-only append prompted it, and replacing the + // evidence with the newer command cutoff would hide the + // competing file. + guessSessionId = newest; + guessPromptText = promptText; + guessPromptAt = promptAt; + guessSince = since; + } + if (newest != null && !newest.equals(activeSessionId)) { + String previous = activeSessionId; + activeSessionId = newest; + claimSession(dir, configDir, newest); + releaseClaim(dir, configDir, previous); + LOG.fine("Session of " + dir + " is now " + newest + + (previous != null ? " (was " + previous + ")" : "")); + } + claimSession(dir, configDir, activeSessionId); + return activeSessionId != null; + } + } + + /** Package-private for tests: reserves a scan slot and returns its sequence. */ + long simulateBeginIdentityScan() { + synchronized (processOwnershipLock) { + return ++scanSequence; + } + } + + /** Package-private for tests: publishes a result scanned for an older process run. */ + boolean simulatePublishStaleScanResult(long generation, String newest) { + return publishIdentityScanResult(generation, simulateBeginIdentityScan(), + null, activeConfigDir, newest, false, null, 0L, 0L, false); + } + + /** Package-private for tests: publishes a scan result with explicit ordering. */ + boolean simulatePublishScanResult(long generation, long scanId, String newest) { + return publishIdentityScanResult(generation, scanId, null, activeConfigDir, newest, + false, null, 0L, 0L, false); + } + + /** Package-private for tests: the identity inputs {@link #refreshSessionIdentity()} captures. */ + record IdentityCapture(long generation, boolean hasConfigDir, long since) { } + + /** Package-private for tests: samples the identity inputs under the ownership lock. */ + IdentityCapture simulateCaptureIdentityInputs() { + synchronized (processOwnershipLock) { + return new IdentityCapture(processGeneration, activeConfigDir != null, + Math.max(processStartedAt, promptSubmittedAt)); + } + } + + /** + * Runs the periodic identity refresh when it is due: on every call while a + * {@code NEW} session is still unknown after a prompt, otherwise every + * tenth call (~5 s at the 500 ms poll cadence). Call from a worker thread — + * it touches the session directory. + */ + void refreshSessionIdentityIfDue() { + boolean unresolvedAfterPrompt = activeSessionId == null && promptSubmittedAt > 0; + if (unresolvedAfterPrompt || ++identityPollTicks % 10 == 0) { + refreshSessionIdentity(); + } + } + + /** + * Queues one periodic identity refresh on the shared worker, so that the + * screen poll never enumerates the session store on the EDT. At most one + * scan per controller is in flight at a time. + * + *

Nothing is queued once the PTY has exited: the tab can stay open after + * Claude quits, and a dead process has no conversation to follow. Without + * this gate each such tab would keep enumerating the session store twice a + * second (unresolved session) or every five seconds on the single shared + * worker, delaying live tabs. + * + * @return {@code false} when no PTY is running and nothing was queued + */ + boolean scheduleIdentityRefresh() { + if (!isProcessLive()) return false; + if (!identityScanPending.compareAndSet(false, true)) return true; + try { + IDENTITY_SCANNER.execute(() -> { + try { + refreshSessionIdentityIfDue(); + } catch (RuntimeException e) { + LOG.fine("Session identity scan failed: " + e); + } finally { + identityScanPending.set(false); + } + }); + } catch (java.util.concurrent.RejectedExecutionException e) { + identityScanPending.set(false); + } + return true; + } + + /** + * Forgets everything recorded about the terminal's input line: the text + * typed so far, the cursor, a partial escape sequence and the history-recall + * flags. Called when the plugin itself drives the TUI (a picker selection) + * or when a run ends, so stale text never becomes prompt evidence. + */ + private void resetTerminalInputRecording() { + resetTerminalInputRecording(false); + } + + /** + * {@link #resetTerminalInputRecording()} that can keep the marker retiring + * a choice menu the terminal input already dismissed. + * + *

A GUI send rewrites the recorder, but it does not bring the picker + * back: cancelling it with Esc the moment before the send must keep it + * retired, or the stale model entry classifies the next prompt typed + * before the screen poll as another menu answer and swallows it. + * + * @param keepCompletedMenu {@code true} to preserve the dismissed-menu + * marker; process resets clear it + */ + private void resetTerminalInputRecording(boolean keepCompletedMenu) { + synchronized (terminalInputLine) { + terminalInputLine.setLength(0); + terminalInputCursor = 0; + terminalInputEscape = 0; + terminalEscapeParams.setLength(0); + terminalInputPaste = false; + terminalPasteAfterCR = false; + terminalEscapeOnMenu = false; + terminalEscapeOnPicker = false; + terminalMenuSelection = false; + terminalSelectedMenu = null; + terminalLineEntered = false; + terminalEnterOnEmptyLine = false; + terminalHistoryRecall = false; + terminalCompletionEdited = false; + terminalRecalledSubmission = false; + terminalInputSinceGuiSend = false; + terminalEnterSinceGuiSend = false; + terminalDraftAfterEnter = false; + terminalGuiSubmitAt = 0L; + terminalGuiLineEdited = false; + guiSendAwaitingEnter = false; + terminalMenuConsumedGuiSend = false; + terminalGuiTextAnsweredMenu = false; + pendingGuiPromptText = null; + pendingGuiPromptAt = 0L; + pendingGuiPromptExact = false; + pendingGuiPromptSet = false; + pendingGuiPromptSwitch = false; + pendingGuiPromptResume = false; + if (!keepCompletedMenu) { + terminalCompletedMenu = null; + } + } + } + + /** + * Discards the recorded input after a choice menu was dismissed from the + * terminal — by a standalone {@code ESC} or by a digit selecting an item + * of a numbered menu: the line then holds the menu's search text, not a + * prompt draft. Call while holding the {@link #terminalInputLine} lock. + */ + private void discardPickerSearchText() { + terminalInputLine.setLength(0); + terminalInputCursor = 0; + terminalEscapeOnMenu = false; + terminalEscapeOnPicker = false; + // The cancelled menu stays in the model until the next poll: retire + // it, or input typed right after the cancellation is still treated as + // a menu answer. + completeTerminalMenu(openChoiceMenu()); + } + + /** + * Returns the choice menu still open on the screen, or {@code null} when + * the terminal input already completed the menu the model reports. + * + *

The model is refreshed by the screen poll every half second, so a + * menu dismissed by a keystroke remains reported for a while. Classifying + * the following input against it would swallow a real prompt: its digits + * are dropped and its Enter counts as a menu selection instead of a + * submission. Call while holding the {@link #terminalInputLine} lock. + */ + private ChoiceMenuModel openChoiceMenu() { + ChoiceMenuModel active = model.getActiveChoiceMenu(); + return active == terminalCompletedMenu ? null : active; + } + + /** + * Remembers that the terminal input has dismissed {@code menu}, so the + * stale model entry no longer classifies the following keystrokes. Call + * while holding the {@link #terminalInputLine} lock. + */ + private void completeTerminalMenu(ChoiceMenuModel menu) { + if (menu != null) { + terminalCompletedMenu = menu; + if (terminalMenuFocusOwner == menu) { + terminalMenuFocusOwner = null; + terminalMenuFocusIndex = -1; + } + } + } + + /** + * Returns the option a choice menu's cursor sits on: the position the + * terminal input moved it to, if it moved since the last screen poll, or + * the poll's report otherwise. Call while holding the + * {@link #terminalInputLine} lock. + */ + private int focusedMenuOption(ChoiceMenuModel menu) { + if (menu == terminalMenuFocusOwner + && terminalMenuFocusIndex >= 0 + && terminalMenuFocusIndex < menu.options().size()) { + return terminalMenuFocusIndex; + } + return menu.defaultOptionIndex(); + } + + /** + * Records that the terminal input moved {@code menu}'s cursor to + * {@code index}, which the next screen poll will confirm. Call while + * holding the {@link #terminalInputLine} lock. + */ + private void trackMenuFocus(ChoiceMenuModel menu, int index) { + terminalMenuFocusOwner = menu; + terminalMenuFocusIndex = index; + } + + /** + * Moves the tracked menu cursor to the option a plugin write just put the + * TUI cursor on. Called from the answer workers, so it takes the recorder's + * lock itself; the state it updates is what + * {@link #digitSelectionDismissesMenu} reads for a digit typed into the + * terminal while the write is still settling. The workers reach the write + * and this call inside one lock section, so input classified between them + * cannot see the previous option. + */ + private void trackForwardedMenuFocus(ChoiceMenuModel menu, int index) { + if (menu == null || menu.options().isEmpty()) return; + int clamped = Math.max(0, Math.min(index, menu.options().size() - 1)); + synchronized (terminalInputLine) { + // The recorder classifies terminal input against the instance the + // model reports, while detectCurrentMenu() re-parses the screen into + // a new instance on every poll: recording the detected instance + // would never match (see focusedMenuOption). The forwarded position + // is carried over to the active instance by the option it names. + ChoiceMenuModel active = openChoiceMenu(); + if (active == null || active.options().isEmpty()) return; + trackMenuFocus(active, optionIndexIn(active, menu.options().get(clamped))); + } + } + + /** + * Returns the index of {@code option} in {@code menu}, matching the label + * and the answer it stands for, or the menu's reported cursor when the + * option is not part of it (a detection of a different screen state). + */ + private static int optionIndexIn(ChoiceMenuModel menu, ChoiceMenuModel.Option option) { + for (int i = 0; i < menu.options().size(); i++) { + ChoiceMenuModel.Option candidate = menu.options().get(i); + if (java.util.Objects.equals(candidate.display(), option.display()) + && java.util.Objects.equals(candidate.response(), option.response())) { + return i; + } + } + return menu.defaultOptionIndex(); + } + + /** + * Returns whether {@code menu} is a checkbox menu, which submits on Right + * (the answer protocol written by the menu panel) instead of a numbered + * picker, where Right only moves the cursor inside the search field. + */ + private static boolean isCheckboxMenu(ChoiceMenuModel menu) { + for (ChoiceMenuModel.Option option : menu.options()) { + if (option.hasCheckbox()) return true; + } + return false; + } + + /** + * Returns whether the digit {@code digit} typed while {@code menu} is open + * selects an item and dismisses the menu. + * + *

Only menus whose answers are the item numbers behave that way. An + * unnumbered picker (the {@code ARROW:} answers of the {@code /resume} + * list) uses digits as search text and stays open, and a checkbox or + * text-input option only moves to that option on a digit, waiting for its + * submit key — retiring the menu on such a digit would turn the search + * text into prompt evidence. A text-input option is recognised by its flag + * or by its label ({@code "Type something."}), like everywhere else in the + * answer protocol: the digit only activates its editor. + * + *

The digit's own option decides. A menu can mix ordinary items with a + * text-input one: selecting an ordinary item still closes the TUI menu, so + * the menu must be retired only when the selected option is not text input. + * A digit that names no option selects nothing at all — the TUI keeps the + * menu open — so it must not retire the menu either. + * + *

The focused option decides as well: while a text-input option already + * holds the cursor, a printed digit is text for its editor, not the number + * of another item (the answer protocol navigates such a menu with arrows, + * see {@code writePtyAnswer}). Retiring the menu then would record the rest + * of the answer as a submitted prompt. + * + *

The cursor the terminal input moved ({@link #focusedMenuOption}) is + * what counts: the screen poll is what updates the model's index, so every + * digit and arrow typed before it must be classified against the position + * they left the cursor at. + * + *

Call while holding the {@link #terminalInputLine} lock. + */ + private boolean digitSelectionDismissesMenu(ChoiceMenuModel menu, char digit) { + if (menu.options().isEmpty()) return false; + int focused = focusedMenuOption(menu); + if (focused >= 0 && focused < menu.options().size() + && isTextInputOption(menu.options().get(focused))) { + // The cursor sits in a text editor: the digit is input for it, so + // the menu stays open. + return false; + } + boolean matched = false; + for (ChoiceMenuModel.Option option : menu.options()) { + String response = option.response(); + if (response == null || response.isEmpty()) return false; + for (int i = 0; i < response.length(); i++) { + char c = response.charAt(i); + if (c < '0' || c > '9') return false; + } + if (response.length() != 1 || response.charAt(0) != digit) continue; + if (option.hasCheckbox() || isTextInputOption(option)) { + // The digit only activates this option's editor or toggles it. + return false; + } + matched = true; + } + return matched; + } + + /** + * Returns the index of the text-input option that the digit {@code digit} + * activates instead of selecting an item, or {@code -1} when the digit + * names none. Call while holding the {@link #terminalInputLine} lock. + */ + private static int activatedTextEditor(ChoiceMenuModel menu, char digit) { + for (int i = 0; i < menu.options().size(); i++) { + ChoiceMenuModel.Option option = menu.options().get(i); + String response = option.response(); + if (response == null || response.length() != 1 || response.charAt(0) != digit) { + continue; + } + if (isTextInputOption(option)) return i; + } + return -1; + } + + /** Package-private for tests: the PTY is no longer running (Claude exited). */ + void simulateProcessNotRunning() { + claudeProcess = new ClaudeProcess(); + } + + /** + * Returns whether this tab only follows {@code sessionId} written by + * another live tab. A borrowed conversation is not this tab's own, even + * when its file matched the own-prompt evidence before the tab's own file + * was flushed. + */ + private boolean isBorrowedByThisTab(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + SessionKey key = keyFor(dir, configDir, sessionId); + SessionClaims claims = key != null ? SESSION_CLAIMS.get(key) : null; + return claims != null && claims.borrowed.contains(this); + } + + /** + * Returns whether {@code sessionId} is a conversation this launch-known tab + * can only be following: a file other than the launch identity that another + * live tab holds as well. The claim registry marks a tab as a borrower only + * if the other tab had already claimed the file when this tab adopted it, + * so two tabs submitting the same text at once can both end up as + * provisional holders; whichever claimed the file first must not let it + * outrank the launch conversation that actually holds the prompt. + */ + private boolean isSharedGuess(String sessionId, Set otherLiveClaims) { + return sessionId != null && launchIdentityKnown && launchSessionId != null + && !sessionId.equals(launchSessionId) + && otherLiveClaims.contains(sessionId); + } + + private static SessionKey keyFor(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + if (dir == null || configDir == null || sessionId == null) return null; + return new SessionKey(configDir.toAbsolutePath().normalize(), + dir.toPath().toAbsolutePath().normalize(), sessionId); + } + + private void claimSession(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + SessionKey key = keyFor(dir, configDir, sessionId); + if (key == null) return; + // Membership and empty-entry cleanup stay inside compute() so a claim + // cannot be added to an owner set that a concurrent release just + // detached from the map (which would silently lose the claim). + SESSION_CLAIMS.compute(key, (k, claims) -> { + SessionClaims updated = claims != null ? claims : new SessionClaims(); + if (!updated.holders.contains(this)) { + // Classify only a new hold: re-claiming the identity on every + // scan must not turn an owner into a borrower of its own file. + if (launchIdentityKnown && sessionId.equals(launchSessionId) + && !heldAsOwnerByAnotherLiveTab(updated)) { + // An explicit --continue/--resume knows the conversation it + // launched on, and only that one: a conversation adopted + // later (a sibling's file this tab started following) is + // not established by it and must stay borrowed, or the + // sibling that created it would lose it. A NEW tab's + // provisional guess does not establish the file either. + updated.owners.add(this); + } else if (heldByAnotherLiveTab(updated)) { + // Another tab is already writing this conversation; this + // tab is only following it. + updated.borrowed.add(this); + } + // Otherwise the claim stays provisional: this NEW tab guessed + // that the file is its own. + updated.holders.add(this); + } + return updated; + }); + } + + /** {@code true} if another live tab has established (owns) {@code claims}. */ + private boolean heldAsOwnerByAnotherLiveTab(SessionClaims claims) { + for (ClaudeSessionController owner : claims.owners) { + if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { + return true; + } + } + return false; + } + + /** {@code true} if another live tab already holds {@code claims}. */ + private boolean heldByAnotherLiveTab(SessionClaims claims) { + for (ClaudeSessionController holder : claims.holders) { + if (holder != this && LIVE_SESSIONS.contains(holder) && holder.isProcessLive()) { + return true; + } + } + return false; + } + + private void releaseClaim(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + SessionKey key = keyFor(dir, configDir, sessionId); + if (key == null) return; + SESSION_CLAIMS.computeIfPresent(key, (k, claims) -> { + releaseFromClaims(claims); + return claims.holders.isEmpty() ? null : claims; + }); + } + + /** Removes all claims held by this controller (called when the process stops). */ + private void releaseAllClaims() { + for (SessionKey key : SESSION_CLAIMS.keySet()) { + SESSION_CLAIMS.computeIfPresent(key, (k, claims) -> { + releaseFromClaims(claims); + return claims.holders.isEmpty() ? null : claims; + }); + } + } + + private void releaseFromClaims(SessionClaims claims) { + claims.holders.remove(this); + claims.owners.remove(this); + claims.borrowed.remove(this); + } + + /** + * Session IDs claimed by another live controller in the same store, even + * when this controller claims them too. A conversation shared with another + * tab is not this tab's own, so its own-prompt evidence must not be taken + * from it; {@link #claimedByOthers} answers the different question of which + * files a scan must not consider at all. + */ + private Set claimedByOtherLiveTabs(java.io.File dir, java.nio.file.Path configDir) { + Set ids = new HashSet<>(); + if (dir == null || configDir == null) return ids; + java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); + java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); + SESSION_CLAIMS.forEach((key, claims) -> { + if (!key.configDir().equals(wantedConfig) + || !key.projectDir().equals(wantedProject)) { + return; + } + for (ClaudeSessionController holder : claims.holders) { + if (holder != this && LIVE_SESSIONS.contains(holder) && holder.isProcessLive()) { + ids.add(key.sessionId()); + return; + } + } + }); + return ids; + } + + /** + * Session IDs another live controller has established in the same + * store. A claim that merely follows the conversation (see + * {@link SessionClaims#borrowed}) is not ownership and is skipped: when a + * NEW tab borrows a live tab's conversation until its own file appears, + * that file must still count as the live tab's own, or the live tab would + * discard its correctly tracked session and adopt the borrower's file. + */ + private Set sessionsOwnedByOtherLiveTabs(java.io.File dir, + java.nio.file.Path configDir) { + Set ids = new HashSet<>(); + if (dir == null || configDir == null) return ids; + java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); + java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); + SESSION_CLAIMS.forEach((key, claims) -> { + if (!key.configDir().equals(wantedConfig) + || !key.projectDir().equals(wantedProject)) { + return; + } + // A file this tab held until it stopped its writer is its own, so + // the post-stop lookup must not treat it as another tab's. + if (preservedClaims.contains(key.sessionId())) return; + for (ClaudeSessionController owner : claims.owners) { + if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { + ids.add(key.sessionId()); + return; + } + } + }); + return ids; + } + + /** + * Package-private for tests and the stop path: session IDs this controller + * established or guessed as its own in the given store. + * + *

A held conversation that this controller only borrowed (another live + * tab established it or was writing it first) is another tab's file and is + * not included: preserving it as this tab's own would let it win the final + * post-stop lookup over the file this tab actually created. The launch + * conversation is the exception: a tab that resumed a saved file another + * live tab had already claimed only borrows it, yet that file is still the + * conversation this tab was opened on, and its rename must target it — a + * merely guessed borrowed file stays excluded. + */ + Set claimedSessionIds(java.io.File dir, java.nio.file.Path configDir) { + Set ids = new HashSet<>(); + if (dir == null || configDir == null) return ids; + java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); + java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); + SESSION_CLAIMS.forEach((key, claims) -> { + if (key.configDir().equals(wantedConfig) + && key.projectDir().equals(wantedProject) + && claims.holders.contains(this) + && (!claims.borrowed.contains(this) + || isLaunchIdentity(key.sessionId()))) { + ids.add(key.sessionId()); + } + }); + return ids; + } + + /** + * Returns whether {@code sessionId} is the conversation this tab launched + * on with {@code --continue}/{@code --resume}. Such a conversation is this + * tab's own even when another live tab claimed it first and this tab is + * therefore only a borrower: the file is not a guess made from matching + * prompt text. + */ + private boolean isLaunchIdentity(String sessionId) { + return launchIdentityKnown && launchSessionId != null + && launchSessionId.equals(sessionId); + } + + /** + * Session IDs claimed by other live controllers in the same store. + * A session ID is not unique across config dirs (a JSONL can be copied + * between the default and an isolated profile), so a claim from another + * store must not hide this store's file. + */ + private Set claimedByOthers(java.io.File dir, java.nio.file.Path configDir) { + Set ids = new HashSet<>(); + if (dir == null || configDir == null) return ids; + java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); + java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); + SESSION_CLAIMS.forEach((key, claims) -> { + if (!key.configDir().equals(wantedConfig) + || !key.projectDir().equals(wantedProject)) { + return; + } + // A session shared with another tab (both resumed the same saved + // conversation) is still this tab's own conversation: excluding it + // would make the scan pick an unrelated file. The same holds for a + // file this tab held before it stopped its writer: the exit + // callback has already released the claim, but that must not turn + // the tab's own conversation into another tab's file. + if (claims.holders.contains(this) + || preservedClaims.contains(key.sessionId())) { + return; + } + for (ClaudeSessionController holder : claims.holders) { + if (holder != this && LIVE_SESSIONS.contains(holder) && holder.isProcessLive() + && !followsOnly(holder, key.sessionId(), claims)) { + ids.add(key.sessionId()); + return; + } + } + }); + return ids; + } + + /** + * Returns whether {@code holder} only follows {@code sessionId}: it is not + * one of the conversation's owners and the conversation is not the one it + * launched on. Such a claim is a guess made from matching prompt text — + * e.g. a launch-known tab that follows a sibling's file while its own + * conversation has not been flushed yet — and must not hide that file from + * the scan of the tab whose process actually writes it, or the creator + * adopts another conversation and loses its own. + */ + private static boolean followsOnly(ClaudeSessionController holder, String sessionId, + SessionClaims claims) { + return !claims.owners.contains(holder) + && holder.launchIdentityKnown + && !sessionId.equals(holder.launchSessionId); + } + + /** + * Package-private for tests: session IDs this controller treats as claimed + * by other live tabs in the same store. + */ + Set simulateClaimedByOthers(java.io.File dir, java.nio.file.Path configDir) { + return claimedByOthers(dir, configDir); + } + + /** + * Returns {@code true} while this controller's PTY is running. Controllers + * without a process (test seams) count as live. + */ + private boolean isProcessLive() { + ClaudeProcess cp = claudeProcess; + return cp == null || cp.isRunning(); + } + + /** + * Releases live-session bookkeeping when the PTY of {@code generation} exits + * on its own (Claude quit or crashed while the tab stayed open). The check + * and the cleanup are atomic with process replacement, so a late callback + * from a process that has already been stopped and replaced by a restart + * cannot wipe the new run's ownership. + */ + private void onProcessExited(long generation) { + synchronized (processOwnershipLock) { + if (generation != processGeneration) return; + // The tab stays open after Claude quits on its own, and Save & + // Switch can still rename the conversation that was on screen. The + // claims are about to be released, so the conversations they held + // are carried into that later lookup; a borrowed conversation that + // is not the launch identity stays eligible to its owner. + java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); + Set held = claimedSessionIds(dir, activeConfigDir); + LIVE_SESSIONS.remove(this); + releaseAllClaims(); + if (!held.isEmpty()) { + Set merged = new HashSet<>(preservedClaims); + merged.addAll(held); + preservedClaims = Set.copyOf(merged); + } + } + } + + /** Package-private for tests: simulates the PTY exiting without stopProcess(). */ + void simulateProcessExit() { + simulateProcessExitAt(simulateProcessGeneration()); + } + + /** Package-private for tests: the ownership generation of the current run. */ + long simulateProcessGeneration() { + return currentProcessGeneration(); + } + + /** + * Package-private for tests: the cleanup a sender of {@code generation} + * runs when its send is cancelled or fails, without its thread timing. + */ + void simulateDiscardGuiSend(long generation) { + discardGuiSend(generation); + } + + /** The ownership generation of the run that is live now. */ + private long currentProcessGeneration() { + synchronized (processOwnershipLock) { + return processGeneration; + } + } + + /** + * Whether {@code generation} is still the live run. A panel send carries + * the generation it was accepted for, so a restart in the meantime cancels + * its remaining writes instead of delivering them to the new process. + */ + private boolean isProcessGenerationCurrent(long generation) { + synchronized (processOwnershipLock) { + return generation == processGeneration; + } + } + + /** + * Returns the PTY connector of {@code generation}, or {@code null} when + * that run is gone or its PTY is not open anymore. + * + *

Binding the connector to the generation the send was accepted for + * makes a write "atomic" with the process it targets: once a restart has + * replaced the connector, a send that was already past its generation + * check keeps writing to the process it was accepted for (whose connector + * then rejects or drops the bytes) and never to the replacement's TUI. + * + * @param generation process generation the writer was accepted for + */ + private PtyTtyConnector ptyConnectorFor(long generation) { + synchronized (processOwnershipLock) { + return generation == processGeneration ? connector : null; + } + } + + /** + * Whether the terminal recorder still belongs to {@code generation}, for a + * sender that already holds {@link #terminalInputLine}. + * + *

The ownership lock cannot be taken there: the stop path resets the + * recorder while holding it, so ownership → recorder is the fixed order + * and this check would reverse it. The generation is volatile instead. A + * bump that lands after this read is still followed by the stop's own + * recorder reset, and the sender's connector is bound to its process, so + * neither its text nor its Enter reaches the replacement. + * + *

A sender that finds its generation stale must leave the recorder + * untouched: reconciling would clear the replacement's draft and install + * pending evidence describing a line the replacement never received, and + * the next panel send would compose only its own text. + * + * @param generation process generation the sender was accepted for + */ + private boolean recorderBelongsTo(long generation) { + return generation == processGeneration; + } + + /** Package-private for tests: replays the exit callback of an older run. */ + void simulateProcessExitAt(long generation) { + onProcessExited(generation); + } + + /** + * Returns the effective config dir of the running session. + * + * @return config dir, or {@code null} when no session was started + */ + public java.nio.file.Path getActiveConfigDir() { + return activeConfigDir; + } + + // ------------------------------------------------------------------------- + // PTY writes + // ------------------------------------------------------------------------- + + /** + * Sends the user's prompt text to the PTY and transitions to WORKING state. + * + *

The text and the {@code \r} Enter key are sent in a background thread + * with a 200 ms pause between them to avoid multiline-input timing issues. + * + *

Also records the prompt in the model's history. + * + * @param text the prompt text to send; must not be empty + */ + public void sendPrompt(String text) { + if (connector == null) return; + model.setLifecycle(SessionLifecycle.WORKING); + if (!text.isBlank()) { + model.addPromptToHistory(text); + } + + // The prompt is accepted for the run that is live now: a restart while + // this send waits for its turn (or during its pause) must not deliver + // it to the replacement process, where it would execute in a + // conversation the user never typed it for. + queueGuiSend(text, currentProcessGeneration()); + } + + /** + * Submits a panel send to the ordered send queue (see {@link #guiSendQueue}). + * + * @param text prompt text + * @param sendGeneration process generation the send was accepted for + * @return the queued task + */ + private java.util.concurrent.Future queueGuiSend(String text, long sendGeneration) { + return queueGuiSend(text, sendGeneration, + System::currentTimeMillis, System::currentTimeMillis); + } + + /** + * {@link #queueGuiSend(String, long)} with controllable clocks, so a test + * can pin the text-write and submission times of a queued send. + */ + private java.util.concurrent.Future queueGuiSend(String text, long sendGeneration, + java.util.function.LongSupplier writeClock, + java.util.function.LongSupplier enterClock) { + java.util.concurrent.ExecutorService queue; + synchronized (guiSendQueueLock) { + if (guiSendQueue == null || guiSendQueue.isShutdown()) { + guiSendQueue = java.util.concurrent.Executors.newSingleThreadExecutor( + runnable -> { + Thread t = new Thread(runnable, "pty-send-prompt"); + t.setDaemon(true); + return t; + }); + } + queue = guiSendQueue; + } + try { + return queue.submit(() -> runGuiSend(text, sendGeneration, + writeClock, enterClock)); + } catch (java.util.concurrent.RejectedExecutionException ex) { + // The session was stopped between the lookup and the submission: + // the queue is gone, so the send is dropped with it. + LOG.fine("sendPrompt dropped: the send queue was shut down"); + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + } + + /** + * Drops the queued panel sends and interrupts the one in progress. The + * next send starts a new queue; a send interrupted while it pauses cannot + * deliver its Enter because the generation it was accepted for is gone. + */ + private void shutdownGuiSendQueue() { + java.util.concurrent.ExecutorService queue; + synchronized (guiSendQueueLock) { + queue = guiSendQueue; + guiSendQueue = null; + } + if (queue != null) queue.shutdownNow(); + } + + /** + * Body of a queued panel send: writes the text, pauses and writes the + * Enter that submits it. + * + *

The worker is the only production caller, so its sends run in + * acceptance order; the monitor also keeps them from interleaving with a + * send driven by {@link #simulateSendPrompt}. + * + * @param text prompt text + * @param sendGeneration process generation the send was accepted for + * @param writeClock time source for the text write + * @param enterClock time source for the submission + */ + private void runGuiSend(String text, long sendGeneration, + java.util.function.LongSupplier writeClock, + java.util.function.LongSupplier enterClock) { + synchronized (guiSendLock) { + if (Thread.currentThread().isInterrupted()) { + // Another thread cancelled the queue (a stop that must take its + // identity snapshot): a task that already started and waited + // here for the monitor is cancelled the same way as one still + // queued, so it writes nothing. + LOG.fine("sendPrompt dropped: the send queue was cancelled"); + return; + } + if (!isProcessGenerationCurrent(sendGeneration)) { + LOG.fine("sendPrompt dropped: the session was restarted"); + return; + } + try { + // Reconciliation and the text write run as one step under the + // recorder's lock: an Enter arriving in between would + // submit the terminal draft alone, while the evidence + // recorded for the send would claim the composed line was + // submitted. + writeGuiPrompt(text, writeClock, sendGeneration); + pauseGuiSend(); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + // The partial line is dropped, but only if the recorder still + // holds this run's state (see discardGuiSend). + discardGuiSend(sendGeneration); + return; + } catch (InterruptedException ie) { + // shutdownGuiSendQueue() interrupts the worker. The flag is + // not restored: the worker is about to stop, and a stale flag + // would make the next queued pause fail immediately. + // A terminal Enter may have submitted the panel line + // during the pause. That submission is real and the + // completion below will not run for it, so it must become + // evidence before the recorder is cleared. + discardGuiSend(sendGeneration); + return; + } + if (!isProcessGenerationCurrent(sendGeneration)) { + // The tab was restarted after the text had already been + // written: this Enter would submit it to the replacement + // process's line instead of closing the old one. The + // recorder now belongs to the new run, so it is not reset + // here either. + LOG.fine("sendPrompt Enter dropped: the session was restarted"); + return; + } + completeGuiSend(enterClock, sendGeneration); + } + } + + /** + * Pauses between a panel send's text and its Enter. Overridable in tests + * to hold a send open. + */ + void pauseGuiSend() throws InterruptedException { + Thread.sleep(200); + } + + /** + * Records a prompt sent from the prompt panel and writes its text to the + * PTY as one step, atomic with respect to terminal input (see + * {@link #reconcileGuiSend(String, long)}). + * + *

The submission time is sampled inside the lock: a terminal submission + * that completed just before must not be timed after the GUI prompt, or + * the evidence lookup would skip the GUI prompt for being older than the + * terminal input. + * + *

Only the panel text is written: the terminal's own draft is still in + * the TUI's input line, so it is already part of the line the Enter + * submits. Writing the composed line would duplicate it. + * + *

The connector is bound to {@code sendGeneration} before the recorder + * lock is taken: a restart that replaces the PTY while this write waits + * for the lock must not deliver the text to the replacement process's + * line. The bound connector may be the old one or {@code null}; either way + * the accepted process — not the current one — is the target. + * + * @param text prompt sent from the prompt panel + * @param clock submission-time source + * @param sendGeneration process generation the send was accepted for + */ + private void writeGuiPrompt(String text, java.util.function.LongSupplier clock, + long sendGeneration) throws IOException { + PtyTtyConnector target = ptyConnectorFor(sendGeneration); + synchronized (terminalInputLine) { + if (!recorderBelongsTo(sendGeneration)) { + // The restart happened while this sender waited for the lock: + // the recorder now describes the replacement's line, and + // reconciling here would clear its draft for a prompt it never + // received. The bound connector keeps the text out of it too. + LOG.fine("sendPrompt write dropped: the recorder was replaced"); + return; + } + reconcileGuiSend(text, clock.getAsLong()); + retireMenuAnsweredByGuiText(text); + writePty(text, target); + } + } + + /** + * Retires a numbered menu the panel text answers on its own: a single + * digit written into an ordinary numbered menu selects that item at once — + * the answer protocol sends such digits without Enter (see + * {@link #writePtyAnswer(String)}) — so the menu is gone before the delayed + * Enter. Leaving the stale model entry in place would make it swallow a + * prompt typed during the pause as one more menu answer, and the search + * text of a menu that is no longer open is not prompt evidence. Call while + * holding the {@link #terminalInputLine} lock. + * + * @param text panel text just written to the PTY + */ + private void retireMenuAnsweredByGuiText(String text) { + if (text == null || text.length() != 1) return; + char digit = text.charAt(0); + if (digit < '0' || digit > '9') return; + ChoiceMenuModel menu = openChoiceMenu(); + if (menu == null || !digitSelectionDismissesMenu(menu, digit)) { + int activated = menu != null ? activatedTextEditor(menu, digit) : -1; + if (activated >= 0) { + // The sent digit activated the menu's text editor: the digits + // that follow are its text until the poll confirms the focus. + trackMenuFocus(menu, activated); + } + return; + } + completeTerminalMenu(menu); + terminalGuiTextAnsweredMenu = true; + if (isConversationPicker(menu)) { + // Picking an entry changes the conversation, and the digit says + // nothing about which one was picked. + confirmPickerSwitch(); + conversationSwitchAt = System.currentTimeMillis(); + } + } + + /** + * Records a prompt sent from the prompt panel as the evidence the identity + * scan matches, composing it with a draft still open in the terminal. + * + *

The plugin writes the prompt at the TUI's cursor, so a draft typed + * there is part of the line the conversation receives. The composed line is + * kept as pending evidence — published only by {@link #completeGuiSend()}, + * once its Enter is written — and the terminal line is forgotten: the + * panel's submission consumed it, so it must not prefix the next line typed + * in the terminal either. + * + * @param text prompt sent from the prompt panel + * @param sentAt submission time + * @return the line the conversation receives + */ + private String reconcileGuiSend(String text, long sentAt) { + String submitted; + boolean isTextExact; + synchronized (terminalInputLine) { + if (terminalInputEscape == 1 && terminalEscapeOnMenu) { + // A standalone Esc cancelled the picker and nothing has been + // typed since to consume it: the GUI send is the next input, + // and the picker's search text must not become part of the + // submitted line. + if (terminalEscapeOnPicker) { + // The cancelled menu was the /resume picker, so the + // conversation never switched: the evidence the command + // replaced has to come back, or the stop skips the rename. + restorePickerSwitch(); + } + discardPickerSearchText(); + terminalInputEscape = 0; + } + String draft = terminalInputLine.toString(); + int cursor = Math.min(terminalInputCursor, draft.length()); + submitted = draft.substring(0, cursor) + text + draft.substring(cursor); + // A history recall or a Tab completion leaves only a part of the + // line in the recorder: composing it would publish text the + // conversation never received, and the exact-match search would + // miss the real prompt. + isTextExact = !terminalHistoryRecall && !terminalCompletionEdited; + // Reset under the same lock: a raw terminal write interleaved + // between the snapshot and the reset would reach the TUI after + // `submitted` was computed, yet its recording would be deleted, + // leaving evidence that omits characters the conversation got. + // The dismissed-menu marker survives: a picker cancelled right + // before the send must not come back through the stale model entry. + resetTerminalInputRecording(true); + // The panel text stays on the TUI's input line until + // completeGuiSend() sends its Enter. Until then a terminal Enter + // submits that text together with the keys typed here, so the + // terminal parser's line is only a part of the prompt. + guiSendAwaitingEnter = true; + // Nothing becomes own-session evidence before the Enter is written, + // whatever the accuracy of the text: the process has submitted + // nothing yet, and an identity scan running during the pause could + // adopt an unrelated conversation's file — by matching the same + // prompt, or, for a partial line, by time alone. The whole line is + // kept here and published by completeGuiSend(). + pendingGuiPromptText = submitted; + pendingGuiPromptAt = sentAt; + pendingGuiPromptExact = isTextExact; + pendingGuiPromptSet = true; + // A TUI command is not a prompt written to the current + // conversation: /resume and /clear can switch it, and these + // writes are made by the plugin, so no terminal input marks + // the switch. The line the process submits is the composed one, + // so a command split across a terminal draft and the panel text + // counts; a recalled draft makes the composition inexact, and + // then only the panel text is trusted. The switch itself stays + // pending until the Enter is written: the line can still be + // edited away during the pause. + pendingGuiPromptSwitch = isConversationCommand(isTextExact ? submitted : text); + // /clear switches at once, but a submitted /resume only opens the + // picker: its switch waits for a selection, which can be cancelled. + pendingGuiPromptResume = isResumeCommand(isTextExact ? submitted : text); + } + return submitted; + } + + /** + * Publishes the pending prompt evidence of a send whose line a terminal + * Enter already submitted, then clears the recorder of the cancelled send. + * + *

{@link #completeGuiSend()} normally publishes that evidence, but a stop + * cancels the sender during its pause and that completion never runs. The + * conversation did receive the prompt, and the stop's identity lookup needs + * its text to follow it: without this evidence the submission is known only + * as unknown text, and a newer user record in another conversation wins the + * time match. + * + *

Only a submission recorded by the terminal Enter counts + * ({@link #terminalGuiSubmitAt}): an Enter that selected a choice menu never + * submits the panel line, and its text is the menu's answer, not a prompt. + * Whether keys typed after that Enter are a new, unsubmitted draft does not + * matter: they start a new line, so the text submitted remains exact + * ({@link #terminalGuiLineEdited} only captures edits made before it). + * + *

Publication and the reset share one critical section: input arriving + * in between would be cleared as if it belonged to the cancelled send. The + * accepted generation is rechecked inside it for the same reason: a stop + * may replace the process while this sender waits for the interrupt to be + * delivered, and the recorder then belongs to the replacement, whose draft + * must survive. + * + * @param generation the process generation the cancelled send was accepted for + */ + private void discardGuiSend(long generation) { + synchronized (terminalInputLine) { + if (!recorderBelongsTo(generation)) { + LOG.fine("sendPrompt cleanup dropped: the recorder was replaced"); + return; + } + long submittedAt = terminalGuiSubmitAt; + // Sampled before the submission publishes its own evidence: a + // /resume only opens the picker, and the evidence it replaced is + // what a cancelled picker has to put back. + long switchBeforeSend = conversationSwitchAt; + long promptBeforeSend = promptSubmittedAt; + long externalBeforeSend = externalInputAt; + String textBeforeSend = lastPromptText; + if (submittedAt != 0L && !terminalGuiLineEdited && pendingGuiPromptSet + && promptSubmittedAt <= submittedAt) { + // The line the panel text was written to was submitted by that + // Enter and nothing edited it: the text is exact evidence. Keys + // typed before it made the line unknown, and a later terminal + // submission already recorded its own evidence; both are left + // in place. + rememberSentPrompt(pendingGuiPromptText, submittedAt, pendingGuiPromptExact); + if (pendingGuiPromptSwitch) { + // The command's line was submitted untouched, so it switched. + if (pendingGuiPromptResume) { + // A submitted /resume only opens the picker: keep the + // evidence of the conversation it may fail to leave. + armPickerSwitch(switchBeforeSend, promptBeforeSend, + externalBeforeSend, textBeforeSend); + } + conversationSwitchAt = submittedAt; + } + } + resetTerminalInputRecording(true); + } + } + + /** + * Writes the Enter that submits a prompt sent from the panel and closes the + * terminal recording for it. The second half of {@link #sendPrompt(String)}. + * + *

The Enter and the reset of the recorder run under the recorder's lock, + * so keys typed between the prompt write and this Enter — which are part of + * the line the TUI submits — cannot stay behind as the draft of the next + * line. Any such input, even an edit that leaves the recorded line empty + * (Backspace, cursor keys, Tab), makes the submitted text unknown: the + * composed text prepared by {@link #reconcileGuiSend} no longer describes + * that line. + * + *

When the line was not edited, this is where the composed text becomes + * own-session evidence: publishing it while the prompt was still waiting + * for its Enter would let an identity scan adopt another conversation that + * receives the same text before this process has submitted anything. The + * same holds for a line whose text is only partially known (a history + * recall or a Tab completion): its submission time is evidence too, and + * before the Enter it would let the scan adopt an unrelated conversation + * by time alone. + * + *

The evidence is published in the same critical section as the Enter + * and the reset, so a terminal submission that arrives afterwards always + * sees the GUI evidence already written. Publishing after releasing the + * lock could overwrite a newer terminal prompt with the older panel one + * while leaving the later external-input time in place, which would make + * the scan skip both prompt-evidence branches. + * + *

A terminal Enter pressed during that pause submits the line first, and + * this Enter then submits an empty one. The evidence of the first + * submission is kept as it is — its time is never later than the record + * the child wrote for it, and the panel text is still exact when the line + * was not edited. A draft typed after that Enter starts a new line, and + * this Enter submits it: the draft is recorded — with this Enter's time — + * instead of being dropped as a no-op. + * + *

The cutoff is the time of the Enter that actually submits the line — + * this one, or the terminal Enter that submitted it during the pause — not + * the text-write time: a record written during the pause by an unrelated + * conversation could otherwise qualify as this submission before the real + * one has been flushed. + */ + void completeGuiSend() { + completeGuiSend(System::currentTimeMillis, currentProcessGeneration()); + } + + /** + * {@link #completeGuiSend()} with a controllable clock, used by tests to + * pin the submission cutoff. + */ + void completeGuiSend(java.util.function.LongSupplier clock) { + completeGuiSend(clock, currentProcessGeneration()); + } + + /** + * {@link #completeGuiSend(java.util.function.LongSupplier)} bound to the + * process the send was accepted for. + * + *

The connector is captured before the recorder's lock is taken: a + * restart can replace the process while this sender waits for that lock, + * and the Enter must then go to the accepted process or nowhere — writing + * through whatever connector is current would submit the replacement + * TUI's line. The captured connector either accepts the bytes or fails + * once closed, and the interrupted pause of the cancelled queue keeps a + * stale sender from getting here at all. + * + * @param clock submission-time clock + * @param generation the process generation the send was accepted for + */ + void completeGuiSend(java.util.function.LongSupplier clock, long generation) { + PtyTtyConnector target = ptyConnectorFor(generation); + if (!isProcessGenerationCurrent(generation)) { + // The accepted process is stopped or replaced: its TUI is gone and + // the recorder already belongs to its successor, so neither an + // Enter nor an evidence reset made here would describe anything. + LOG.fine("completeGuiSend dropped: the process was replaced"); + return; + } + boolean schedule; + synchronized (terminalInputLine) { + if (!recorderBelongsTo(generation)) { + // The restart happened while this sender waited for the lock: + // the recorder belongs to the replacement run, so this Enter + // and the recorder reset would describe a line it never + // submitted. The bound connector keeps the Enter away as well. + LOG.fine("completeGuiSend dropped: the recorder was replaced"); + return; + } + // Sampled before the Enter is forwarded: the child may append the + // submitted prompt to its session file as soon as it reads the + // bytes, and a record written then must still pass the submission + // cutoff used by the evidence lookup. + long submittedAt = clock.getAsLong(); + // A standalone Esc typed during the pause cancelled the choice menu + // the panel text was written into, and nothing has followed it to + // consume the cancellation: the model still reports the menu until + // the next poll. Consume it here, or this Enter is classified as a + // selection of that menu — and for the /resume picker that marks a + // conversation switch the Enter never made. + boolean cancelledPicker = terminalInputEscape == 1 && terminalEscapeOnMenu; + if (cancelledPicker) { + if (terminalEscapeOnPicker) { + // The cancelled menu was the /resume picker: the conversation + // never switched, so the evidence the command replaced comes + // back before this Enter continues on the fresh line. + restorePickerSwitch(); + } + discardPickerSearchText(); + terminalInputEscape = 0; + } + ChoiceMenuModel openMenu = openChoiceMenu(); + if (openMenu != null) { + // A choice menu is open on the screen (with the menu hidden, + // the prompt panel stays usable): the panel text went into + // its search field or answered it, and this Enter selects an + // item instead of submitting a prompt. The search filter is + // not something the conversation receives, so it must not + // become prompt evidence — and the stale model entry must + // not classify the input that follows as another menu + // answer, or the next real prompt is swallowed. + try { + writePty("\r", target); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + resetTerminalInputRecording(true); + return; + } + if (!isCheckboxMenu(openMenu)) { + // A numbered picker closes on its selection Enter (a + // checkbox menu only toggles and stays open until Right). + completeTerminalMenu(openMenu); + } + if (isConversationPicker(openMenu)) { + // Picking an entry changes the conversation, and the + // search filter says nothing about which one was picked: + // the next submission has to follow the switch. + confirmPickerSwitch(); + conversationSwitchAt = submittedAt; + } + resetTerminalInputRecording(true); + return; + } + if (terminalMenuConsumedGuiSend || terminalGuiTextAnsweredMenu + || cancelledPicker) { + // A terminal Enter already answered the choice menu this + // panel text was typed into, the panel text answered it on its + // own, or a terminal Esc cancelled it, so the menu is gone and + // this Enter submits whatever the user typed on the fresh line + // afterwards. The panel text itself was the menu's search + // filter or answer and must not become evidence; the switch of + // a picker was recorded by that Enter or by the write. + String draftLine = terminalInputLine.toString().trim(); + boolean draftExact = !terminalHistoryRecall && !terminalCompletionEdited; + try { + writePty("\r", target); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + resetTerminalInputRecording(true); + return; + } + resetTerminalInputRecording(true); + if (!draftLine.isEmpty() || !draftExact) { + // The menu is gone, so this Enter submits the draft on the + // fresh line — whether it was typed after a terminal Enter + // that answered the menu or after a digit the panel sent. + rememberSentPrompt(draftLine, submittedAt, draftExact); + } + return; + } + // Sampled before the submission below publishes the command's own + // evidence: a submitted /resume only opens the picker, and the + // evidence it replaced is what a cancelled picker has to put back. + long switchBeforeSend = conversationSwitchAt; + long promptBeforeSend = promptSubmittedAt; + long externalBeforeSend = externalInputAt; + String textBeforeSend = lastPromptText; + try { + writePty("\r", target); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + resetTerminalInputRecording(true); + return; + } + boolean edited = terminalInputSinceGuiSend; + boolean alreadySubmitted = terminalEnterSinceGuiSend; + boolean draftAfterEnter = terminalDraftAfterEnter; + long guiSubmitAt = terminalGuiSubmitAt; + boolean guiLineEdited = terminalGuiLineEdited; + String draftLine = terminalInputLine.toString().trim(); + boolean draftExact = !terminalHistoryRecall && !terminalCompletionEdited; + String pendingText = pendingGuiPromptText; + long pendingAt = pendingGuiPromptAt; + boolean pendingExact = pendingGuiPromptExact; + boolean pendingSet = pendingGuiPromptSet; + boolean pendingSwitch = pendingGuiPromptSwitch; + boolean pendingResume = pendingGuiPromptResume; + boolean switched = false; + resetTerminalInputRecording(true); + if (alreadySubmitted && draftAfterEnter + && (!draftLine.isEmpty() || !draftExact)) { + // A new line was started after the terminal Enter that + // submitted the panel text, and this Enter submits it. The + // recorded text is the whole line when anything was typed + // after that Enter (it cleared the recorder); a line recalled + // from the history or completed by Tab leaves no recorded + // text, yet it is submitted all the same, as unknown text. + rememberSentPrompt(draftLine, submittedAt, draftExact); + schedule = false; + } else if (alreadySubmitted && draftAfterEnter && pendingSet + && promptSubmittedAt <= pendingAt) { + // The draft typed after the terminal Enter was deleted again, + // so this Enter submits nothing: the line submitted by that + // Enter is the pending one — the panel text, or the composed + // line when the text was only partially known. A newer + // submission recorded meanwhile (a draft submitted from the + // terminal) is left in place. The panel line itself was not + // edited before that Enter, so a switch command it carried + // did switch. + rememberSentPrompt(pendingText, + guiSubmitAt != 0L ? guiSubmitAt : pendingAt, pendingExact); + switched = pendingSwitch && !guiLineEdited; + schedule = false; + } else if (alreadySubmitted) { + // The terminal's own Enter submitted the line before the + // panel's Enter reached the TUI, and no new line was started + // since. That submission keeps its own time — re-timing it at + // the no-op Enter would put the cutoff after the user record + // the child already wrote. Text typed before that Enter is + // only a part of the submitted line (the panel text is not + // recorded), so the terminal path recorded the submission as + // unknown-text and this Enter must not replace it with a + // wider one. When the terminal path recorded nothing — the + // reconciliation reset the recall/completion flags, so the + // line it saw was empty — the pending submission is published + // here instead, unknown-text or not. + boolean laterSubmission = guiSubmitAt != 0L + && promptSubmittedAt > guiSubmitAt; + if (laterSubmission) { + // A line typed after that Enter was submitted from the + // terminal itself before this Enter arrived: its text is + // fully recorded (the terminal Enter cleared the recorder) + // and it describes the newest submission. This Enter now + // submits an empty line, so that evidence must be kept + // instead of being cleared as an edit of the panel line. + // The scan was scheduled when that submission was seen. + } else if (edited) { + lastPromptText = null; + } else if (pendingSet) { + rememberSentPrompt(pendingText, + guiSubmitAt != 0L ? guiSubmitAt : pendingAt, pendingExact); + } + // The switch command was submitted only if nothing edited the + // line before that Enter and no later submission replaced it. + switched = pendingSwitch && !guiLineEdited && !edited && !laterSubmission; + schedule = true; + } else if (edited) { + // Keys arrived while the panel text was being submitted: their + // position relative to the panel text is not known, so only + // the file times and user records can identify the + // conversation the line went to. + lastPromptText = null; + promptSubmittedAt = submittedAt; + schedule = true; + } else if (pendingSet) { + // The Enter has now submitted the line the reconciliation + // composed, so this Enter's time is its cutoff. The send time + // would reach back into the pause, where an unrelated + // conversation may have received a human record — the text + // match for exact evidence is not enough to tell them apart. + rememberSentPrompt(pendingText, submittedAt, pendingExact); + switched = pendingSwitch; + schedule = false; + } else { + schedule = false; + } + if (switched) { + // The command was submitted, so the conversation did switch: + // the next submission has to follow it rather than the launch + // conversation. The switch time is the submitting Enter's, not + // the text-write time, which would reach back into the pause. + if (pendingResume) { + // A submitted /resume only opened the picker: the evidence + // of the conversation it may fail to leave is kept until an + // entry is picked or the admission is cancelled. + armPickerSwitch(switchBeforeSend, promptBeforeSend, + externalBeforeSend, textBeforeSend); + } + conversationSwitchAt = guiSubmitAt != 0L ? guiSubmitAt : submittedAt; + } + } + if (schedule) { + scheduleIdentityRefresh(); + } + } + + /** + * Records a prompt sent from the plugin as the evidence the identity scan + * matches: its text and the submission time. + * + *

Blank text is not a prompt of this tab. The GUI still permits sending + * it (a lone Enter), but recording it would turn the submission into an + * unknown-text one: with no text of ours in this tab's file, a sibling's + * newer prompt would then win the shared search. The previous evidence is + * kept instead. + */ + private void rememberSentPrompt(String text, long sentAt) { + rememberSentPrompt(text, sentAt, true); + } + + /** + * Records a prompt sent from the plugin as the evidence the identity scan + * matches, or, when only a part of its line is known, as a submission of + * unknown text. + * + * @param text the prompt text, or the part of it that is known + * @param sentAt submission time + * @param isTextExact {@code false} when {@code text} is only a part of the + * submitted line (a history recall or a Tab completion) + */ + private void rememberSentPrompt(String text, long sentAt, boolean isTextExact) { + if (!isTextExact) { + // The recorder saw only a part of the line: publishing it as the + // prompt would make the exact-match search look for text the + // conversation never received. The submission is unknown-text + // instead, so the file times and the user records decide. + lastPromptText = null; + promptSubmittedAt = sentAt; + scheduleIdentityRefresh(); + } else if (!text.isBlank()) { + lastPromptText = text; + promptSubmittedAt = sentAt; + scheduleIdentityRefresh(); + } + } + + /** + * Returns {@code true} when the prompt-panel text is a TUI command that + * changes the conversation the process writes rather than a message in it. + * + * @param text text submitted from the prompt panel + */ + private static boolean isConversationCommand(String text) { + String command = text.stripLeading(); + return command.startsWith("/resume") || command.startsWith("/clear"); + } + + /** + * Returns {@code true} when {@code text} is the {@code /resume} command, + * the only conversation command whose switch only happens once an entry is + * picked: its picker can still be cancelled, leaving the conversation where + * it was. * - * @param sessionName name to persist, or {@code null}/blank to skip renaming + * @param text text submitted from the prompt panel or from the terminal */ - public void stopAndRename(String sessionName) { - java.io.File workingDir = model.getWorkingDirectory(); - stopProcess(); - if (sessionName != null && !sessionName.isBlank() && workingDir != null) { - try { - SavedSession recent = ClaudeSessionStore.findMostRecent( - workingDir.toPath(), null); - if (recent != null) { - ClaudeSessionStore.renameSession( - workingDir.toPath(), null, recent.sessionId(), sessionName); - LOG.fine("Renamed session " + recent.sessionId() + " to '" + sessionName + "'"); - } - } catch (Exception e) { - LOG.warning("Could not rename session: " + e.getMessage()); - } - } + private static boolean isResumeCommand(String text) { + return text != null && text.stripLeading().startsWith("/resume"); } - // ------------------------------------------------------------------------- - // PTY writes - // ------------------------------------------------------------------------- + /** + * Arms the switch a submitted {@code /resume} is about to record: the + * evidence it replaces is kept until the picker confirms a conversation + * ({@link #confirmPickerSwitch()}) or the admission is cancelled + * ({@link #restorePickerSwitch()}). The first arm wins — a command + * submitted while an earlier admission is still pending did not switch + * anything either, so the snapshot must keep reaching back to before both. + * Call while holding the {@link #terminalInputLine} lock, before setting + * {@link #conversationSwitchAt}. + */ + private void armPickerSwitch() { + armPickerSwitch(conversationSwitchAt, promptSubmittedAt, externalInputAt, + lastPromptText); + } /** - * Sends the user's prompt text to the PTY and transitions to WORKING state. - * - *

The text and the {@code \r} Enter key are sent in a background thread - * with a 200 ms pause between them to avoid multiline-input timing issues. - * - *

Also records the prompt in the model's history. + * {@link #armPickerSwitch()} with a snapshot taken before the submission + * that recorded the switch replaced it. + */ + private void armPickerSwitch(long switchAt, long promptAt, long externalAt, String text) { + if (pickerSwitchPending) return; + pickerSwitchPending = true; + switchBeforePicker = switchAt; + promptBeforePicker = promptAt; + externalBeforePicker = externalAt; + textBeforePicker = text; + } + + /** + * Disarms the pending switch: an entry was picked, so the conversation did + * change. Call while holding the {@link #terminalInputLine} lock. + */ + private void confirmPickerSwitch() { + pickerSwitchPending = false; + } + + /** + * Puts back the evidence a cancelled {@code /resume} replaced: the picker + * was dismissed without a selection, so the process still writes the + * conversation it wrote before the command. Idempotent. Call while holding + * the {@link #terminalInputLine} lock. + */ + private void restorePickerSwitch() { + if (!pickerSwitchPending) return; + pickerSwitchPending = false; + conversationSwitchAt = switchBeforePicker; + promptSubmittedAt = promptBeforePicker; + externalInputAt = externalBeforePicker; + lastPromptText = textBeforePicker; + } + + /** + * Returns {@code true} when {@code menu} is the session picker opened by + * {@code /resume}. Selecting an item there changes the conversation the + * process writes; other menus (permission prompts, the trust-folder + * confirmation, model pickers) only answer a question in the current + * conversation and must not lift its prompt evidence. * - * @param text the prompt text to send; must not be empty + * @param menu menu the input is answering, or {@code null} */ - public void sendPrompt(String text) { - if (connector == null) return; - model.setLifecycle(SessionLifecycle.WORKING); - if (!text.isBlank()) { - model.addPromptToHistory(text); - } + static boolean isConversationPicker(ChoiceMenuModel menu) { + if (menu == null) return false; + String text = menu.text(); + if (text == null) return false; + return text.stripLeading().toLowerCase(java.util.Locale.ROOT) + .startsWith("resume session"); + } - Thread t = new Thread(() -> { - try { - connector.write(text); - Thread.sleep(200); - connector.write("\r"); - } catch (IOException ex) { - LOG.warning("sendPrompt write failed: " + ex.getMessage()); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); + /** + * Records that the GUI answered a choice menu. An {@code ARROW:n} answer + * selects an entry in an unnumbered picker; only the {@code /resume} + * session picker changes the conversation the process writes, so the + * switch is recorded only for that menu — the answer to the trust-folder + * confirmation or to the history picker is not a resume. The write is made + * by the plugin, so it leaves the terminal-input time alone. + * + * @param answer answer passed to {@link #writePtyAnswer(String)} + */ + void rememberChoiceMenuAnswer(String answer) { + if (answer != null && answer.startsWith("ARROW:") + && isConversationPicker(model.getActiveChoiceMenu())) { + synchronized (terminalInputLine) { + // The picked entry is the switch the /resume was waiting for. + confirmPickerSwitch(); + conversationSwitchAt = System.currentTimeMillis(); } - }, "pty-send-prompt"); - t.setDaemon(true); - t.start(); + } } /** @@ -425,6 +3854,26 @@ public void sendPrompt(String text) { */ public void cancelPrompt() { if (connector == null) return; + synchronized (terminalInputLine) { + ChoiceMenuModel menu = openChoiceMenu(); + boolean pendingPicker = menu == null && pickerSwitchPending; + // Esc dismisses the choice menu on the screen: cancelling the + // /resume picker leaves the conversation where it was. The screen + // poll may not have published the picker yet, so a pending /resume + // admission counts as the picker just the same. + if (isConversationPicker(menu) || pendingPicker) { + restorePickerSwitch(); + } + if (menu != null || pendingPicker) { + // The Esc sent below dismisses the menu on the screen, but this + // write bypasses the recorder, so the model would keep reporting + // it until the next poll: the next panel send's Enter would then + // read as an answer to it and drop the prompt evidence. The text + // typed into the menu's box is not a prompt draft either, so + // both are retired here, atomically with the cancelling write. + discardPickerSearchText(); + } + } try { sendCancelToPty(new byte[]{0x1b}); } catch (IOException ex) { @@ -450,6 +3899,37 @@ public void triggerPromptScan() { SwingUtilities.invokeLater(this::flushPendingPrompt); } + /** + * Returns whether the menu an answer was accepted from stays open in the + * TUI until the answer worker forwards its submitting key. Such an answer + * is written asynchronously — the worker pauses between its writes — and + * terminal input arriving meanwhile belongs to the menu, so the model entry + * must keep classifying it until the worker retires the menu. + * + *

Besides the explicit protocols ({@code TYPE:}, {@code MULTI_TYPE:}, + * {@code MULTI:}, {@code ARROW:}), a numeric answer navigates with arrow + * keys before submitting when the number names an item that cannot be + * typed (10 and above) or when the cursor sits in a text-input option: the + * picker or checkbox menu only closes on the Enter those workers send. + */ + private boolean answerKeepsMenuOpen(String answer) { + if (answer == null) return false; + if (answer.startsWith("TYPE:") || answer.startsWith("MULTI_TYPE:") + || answer.startsWith("MULTI:") || answer.startsWith("ARROW:")) { + return true; + } + if (!answer.matches("[0-9]+")) return false; + ChoiceMenuModel current = detectCurrentMenu(); + int currentIdx = current != null ? current.defaultOptionIndex() : 0; + if (answer.length() == 1) { + boolean currentHasTextInput = current != null + && currentIdx < current.options().size() + && isTextInputOption(current.options().get(currentIdx)); + return currentHasTextInput && answer.charAt(0) - '1' != currentIdx; + } + return menuCursorNumber(current) > 0; + } + /** * Writes the user's answer to the PTY. * @@ -466,6 +3946,29 @@ public void triggerPromptScan() { * @param answer the answer string from the choice-menu callback */ public void writePtyAnswer(String answer) { + rememberChoiceMenuAnswer(answer); + // Choosing an option is the GUI consuming the picker: text typed into + // its search box belongs to the picker, not to the next prompt. The + // answered menu stays in the model until the next poll, so it is + // retired here with the same marker the terminal path uses — a panel + // send in that window must not be mistaken for another menu answer. + // An answer typed into the menu's editor, toggled in a checkbox menu, + // or picked with arrow keys — all of whose submitting keys the worker + // sends after a pause — is the exception: the TUI keeps the menu open + // until that key, and terminal input arriving in between answers the + // menu, so the worker retires it. + if (!answerKeepsMenuOpen(answer)) { + synchronized (terminalInputLine) { + ChoiceMenuModel answeredMenu = openChoiceMenu(); + if (answer == null && isConversationPicker(answeredMenu)) { + // Cancelling the /resume picker leaves the conversation + // where it was: put back the evidence the command replaced. + restorePickerSwitch(); + } + completeTerminalMenu(answeredMenu); + } + } + resetTerminalInputRecording(true); try { if (answer == null) { LOG.fine("[PTY write] \\x1b (Cancel/ESC)"); @@ -489,20 +3992,27 @@ public void writePtyAnswer(String answer) { Thread t = new Thread(() -> { try { for (String digit : toggles) { - connector.write(digit.strip()); + writePty(digit.strip()); Thread.sleep(100); } // Right arrow to submit the selection - connector.write(new byte[]{0x1b, '[', 'C'}); + writePtyAnsweringMenu(new byte[]{0x1b, '[', 'C'}); } catch (IOException ex) { LOG.warning("writePtyAnswer MULTI write failed: " + ex.getMessage()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); + } finally { + // The checkbox menu stays open in the TUI until the + // submitting arrow above, so terminal input arriving + // meanwhile is still the menu's (see the TYPE branch). + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } + clearActiveChoiceMenu(); } }, "pty-multiselect"); t.setDaemon(true); t.start(); - clearActiveChoiceMenu(); } else if (answer.startsWith("MULTI_TYPE:")) { // Format: MULTI_TYPE:checks:typeN:text // e.g. MULTI_TYPE:1,3:5:my text or MULTI_TYPE::5:my text @@ -526,7 +4036,7 @@ public void writePtyAnswer(String answer) { return m != null ? m.options() : List.of(); }); for (String digit : computeCheckboxToggles(wanted, currentOptions)) { - connector.write(digit.strip()); + writePty(digit.strip()); Thread.sleep(100); } // 2. Re-read screen to find current ❯ position @@ -538,27 +4048,34 @@ public void writePtyAnswer(String answer) { ? new byte[]{0x1b, '[', 'A'} : new byte[]{0x1b, '[', 'B'}; for (int i = 0; i < Math.abs(delta); i++) { - connector.write(arrow); + writePty(arrow); Thread.sleep(80); } // 4. Type text Thread.sleep(100); - connector.write(typeText); + writePty(typeText); // 5. Move cursor off type-input field (up arrow) Thread.sleep(100); - connector.write(new byte[]{0x1b, '[', 'A'}); + writePty(new byte[]{0x1b, '[', 'A'}); // 6. Submit with right arrow Thread.sleep(100); - connector.write(new byte[]{0x1b, '[', 'C'}); + writePtyAnsweringMenu(new byte[]{0x1b, '[', 'C'}); } catch (IOException ex) { LOG.warning("writePtyAnswer MULTI_TYPE write failed: " + ex.getMessage()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); + } finally { + // The menu stays open in the TUI until the submitting + // arrow above, so terminal input arriving meanwhile is + // still the menu's (see the TYPE branch). + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } + clearActiveChoiceMenu(); } }, "pty-multitype"); t.setDaemon(true); t.start(); - clearActiveChoiceMenu(); } else if (answer.startsWith("TYPE:")) { int sep = answer.indexOf(':', 5); String digit = answer.substring(5, sep); @@ -578,53 +4095,98 @@ public void writePtyAnswer(String answer) { int delta = targetIdx - currentIdx; if (delta != 0) { byte[] arrow = delta < 0 ? new byte[]{0x1b, '[', 'A'} : new byte[]{0x1b, '[', 'B'}; + int step = Integer.signum(delta); for (int i = 0; i < Math.abs(delta); i++) { - connector.write(arrow); + // The write and the focus update share the + // recorder's lock: input classified between + // them would still see the old option. + synchronized (terminalInputLine) { + writePty(arrow); + // The TUI cursor is on the next option now: + // terminal input arriving during the pause + // belongs to that option, not to the one the + // last poll reported. + trackForwardedMenuFocus(current, currentIdx + (i + 1) * step); + } Thread.sleep(80); } Thread.sleep(100); } - connector.write(text); + writePty(text); Thread.sleep(200); - connector.write("\r"); + writePtyAnsweringMenu("\r"); } else { - connector.write(digit); + synchronized (terminalInputLine) { + writePty(digit); + // The digit activates the type-input option: its + // editor holds the cursor, so a digit typed during + // the pause is text for it, not a selection of + // another item (see digitSelectionDismissesMenu). + trackForwardedMenuFocus(current, targetIdx); + } Thread.sleep(200); - connector.write(text); + writePty(text); Thread.sleep(200); - connector.write("\r"); + writePtyAnsweringMenu("\r"); } } catch (IOException ex) { LOG.warning("writePtyAnswer TYPE write failed: " + ex.getMessage()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); + } finally { + // The menu is still open in the TUI until the Enter above + // reaches it, and terminal input arriving meanwhile (a + // typed answer submitted with Enter) is the menu's, not a + // prompt: the context must stay until the answer is + // actually forwarded. + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } + clearActiveChoiceMenu(); } }, "pty-typeinput"); t.setDaemon(true); t.start(); - clearActiveChoiceMenu(); } else if (answer.startsWith("ARROW:")) { int targetIdx = Integer.parseInt(answer.substring(6)); ChoiceMenuModel current = model.getActiveChoiceMenu(); int currentIdx = current != null ? current.defaultOptionIndex() : 0; int delta = targetIdx - currentIdx; LOG.fine("[PTY write] ARROW targetIdx=" + targetIdx + " currentIdx=" + currentIdx + " delta=" + delta); - clearActiveChoiceMenu(); if (delta == 0) { - connector.write("\r"); + // The picker closes on that Enter: until then its entry + // stays the context for terminal input (see the TYPE + // branch), and it is retired together with the Enter. + writePtyAnsweringMenu("\r"); + clearActiveChoiceMenu(); } else { byte[] arrow = delta < 0 ? new byte[]{0x1b, '[', 'A'} : new byte[]{0x1b, '[', 'B'}; + int step = Integer.signum(delta); Thread t = new Thread(() -> { try { for (int idx = 0; idx < Math.abs(delta); idx++) { - connector.write(arrow); + // The write and the focus update share the + // recorder's lock: input classified between them + // would still see the old option. + synchronized (terminalInputLine) { + writePty(arrow); + trackForwardedMenuFocus(current, currentIdx + (idx + 1) * step); + } Thread.sleep(80); } - connector.write("\r"); + writePtyAnsweringMenu("\r"); } catch (IOException ex) { LOG.warning("writePtyAnswer ARROW write failed: " + ex.getMessage()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); + } finally { + // The picker stays open in the TUI until the + // submitting Enter above, so terminal input + // arriving meanwhile is still the menu's. + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } + clearActiveChoiceMenu(); } }, "pty-arrow-select"); t.setDaemon(true); @@ -643,22 +4205,41 @@ public void writePtyAnswer(String answer) { int currentIdx = currentIdx2; int delta = targetIdx - currentIdx; LOG.fine("[PTY write] ARROW-digit targetIdx=" + targetIdx + " currentIdx=" + currentIdx + " delta=" + delta); - clearActiveChoiceMenu(); if (delta == 0) { - connector.write("\r"); + writePtyAnsweringMenu("\r"); + clearActiveChoiceMenu(); } else { byte[] arrow = delta < 0 ? new byte[]{0x1b, '[', 'A'} : new byte[]{0x1b, '[', 'B'}; + int step = Integer.signum(delta); Thread t = new Thread(() -> { try { for (int i = 0; i < Math.abs(delta); i++) { - connector.write(arrow); + // The write and the focus update share the + // recorder's lock. The arrows leave the + // text-input option, where a digit was text + // for its editor: without the update the + // recorder would swallow the digit that + // selects an ordinary option as more editor + // text, and the prompt after it with it. + synchronized (terminalInputLine) { + writePty(arrow); + trackForwardedMenuFocus(current, currentIdx + (i + 1) * step); + } Thread.sleep(80); } - connector.write("\r"); + writePtyAnsweringMenu("\r"); } catch (IOException ex) { LOG.warning("writePtyAnswer ARROW-digit write failed: " + ex.getMessage()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); + } finally { + // The type-input option keeps the menu open + // until the submitting Enter above, so terminal + // input arriving meanwhile is still the menu's. + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } + clearActiveChoiceMenu(); } }, "pty-arrow-digit"); t.setDaemon(true); @@ -669,13 +4250,12 @@ public void writePtyAnswer(String answer) { // cursor onto the item with arrow keys, then Enter. int delta = Integer.parseInt(answer) - menuCursorNumber(current); LOG.fine("[PTY write] ARROW-number answer=" + answer + " delta=" + delta); - clearActiveChoiceMenu(); sendArrowsThenEnter(delta, "pty-arrow-number"); } else { boolean isMenuDigit = answer.matches("[0-9]"); String toWrite = isMenuDigit ? answer : answer + "\r"; LOG.fine("[PTY write] " + toWrite.replace("\r", "\\r").replace("\n", "\\n")); - connector.write(toWrite); + writePty(toWrite); clearActiveChoiceMenu(); } } @@ -697,14 +4277,22 @@ private void sendArrowsThenEnter(int delta, String threadName) { try { byte[] arrow = delta < 0 ? PTY_ARROW_UP : PTY_ARROW_DOWN; for (int i = 0; i < Math.abs(delta); i++) { - connector.write(arrow); + writePty(arrow); Thread.sleep(80); } - connector.write("\r"); + writePtyAnsweringMenu("\r"); } catch (IOException ex) { LOG.warning(threadName + " write failed: " + ex.getMessage()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); + } finally { + // The menu stays open in the TUI until the submitting Enter + // above, so terminal input arriving meanwhile is still the + // menu's (see the other answer workers). + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } + clearActiveChoiceMenu(); } }, threadName); t.setDaemon(true); @@ -764,7 +4352,7 @@ public void sendShiftTab() { modeSwitchInProgress = true; Thread t = new Thread(() -> { try { - connector.write(new byte[]{0x1b, '[', 'Z'}); + writePty(new byte[]{0x1b, '[', 'Z'}); Thread.sleep(300); screenContentDetector.detectEditMode(screenLines.get()) .ifPresent(model::setEditMode); @@ -971,7 +4559,7 @@ boolean isConnected() { /** Package-private for testing: sends one Shift+Tab (ESC [ Z) to the PTY. */ void sendOneShiftTab() throws IOException { - connector.write(new byte[]{0x1b, '[', 'Z'}); + writePty(new byte[]{0x1b, '[', 'Z'}); } // ------------------------------------------------------------------------- @@ -996,7 +4584,7 @@ public void switchModel(int index) { String customId = customModelIds.get(index - standardModelCount); Thread t = new Thread(() -> { try { - connector.write("/model " + customId + "\r"); + writePtySubmittingLine("/model " + customId + "\r"); Thread.sleep(500); } catch (IOException | InterruptedException ex) { LOG.warning("model switch failed: " + ex.getMessage()); @@ -1013,7 +4601,7 @@ public void switchModel(int index) { try { openModelMenu(); if (index < 9) { - connector.write(String.valueOf(index + 1)); + writePty(String.valueOf(index + 1)); } else { // Items 10+ cannot be typed: move the cursor onto the item, then Enter. int cursor = waitForModelMenuCursor(); @@ -1021,13 +4609,13 @@ public void switchModel(int index) { int delta = index + 1 - cursor; byte[] arrow = delta < 0 ? PTY_ARROW_UP : PTY_ARROW_DOWN; for (int i = 0; i < Math.abs(delta); i++) { - connector.write(arrow); + writePty(arrow); Thread.sleep(80); } - connector.write("\r"); + writePty("\r"); } else { LOG.warning("model switch: /model menu not visible, cannot select item " + (index + 1)); - connector.write(PTY_ESC); + writePty(PTY_ESC); } } Thread.sleep(500); @@ -1110,9 +4698,9 @@ void discoverModels() { // absorbed as a newline — retry: BackSpace to remove the blank line, then CR. if (poll < 12 && isModelCommandPendingWithBlankLine(lines)) { LOG.fine("[discoverModels] /model pending with blank line on poll " + (poll + 1) + ", retrying CR"); - connector.write(PTY_BACKSPACE); + writePty(PTY_BACKSPACE); Thread.sleep(200); - connector.write("\r"); + writePty("\r"); } } @@ -1170,7 +4758,7 @@ void discoverModels() { selIdx = -1; } - connector.write(new byte[]{0x1b}); // Esc — dismiss model menu + writePty(new byte[]{0x1b}); // Esc — dismiss model menu // If a menu entry contains "(currently" but is incomplete (truncated at terminal // width), replace it with the model name extracted from the hint line above. @@ -1236,6 +4824,11 @@ void discoverModels() { * */ void pollScreenState() { + // Keep the session identity — and the cross-tab claims that follow from + // it — in sync with the file Claude is writing, so a conversation switch + // made inside the TUI is noticed by every tab. The scan enumerates the + // session store, so it runs on a worker instead of this (EDT) tick. + scheduleIdentityRefresh(); List lines = screenLines.get(); // Plan name @@ -1601,8 +5194,8 @@ private void showChoiceMenu(ChoiceMenuModel menu) { private MenuEnumerator newMenuEnumerator() { return new MenuEnumerator(screenContentDetector, screenLines, new MenuEnumerator.Keys() { - @Override public void down() throws IOException { connector.write(PTY_ARROW_DOWN); } - @Override public void up() throws IOException { connector.write(PTY_ARROW_UP); } + @Override public void down() throws IOException { writePty(PTY_ARROW_DOWN); } + @Override public void up() throws IOException { writePty(PTY_ARROW_UP); } }, Thread::sleep, MENU_ENUMERATION_SETTLE_MS); } @@ -1782,23 +5375,826 @@ private static boolean isSeparatorLine(String line) { * command in the input), wait, then \r to execute. */ private void openModelMenu() throws IOException, InterruptedException { - connector.write("/model"); + writePty("/model"); Thread.sleep(200); - connector.write(PTY_ESC); + writePty(PTY_ESC); Thread.sleep(200); - connector.write("\r"); + // The Enter submits whatever draft the TUI holds together with the + // command (the write lands at its cursor), so the recorded draft is + // consumed and must not prefix the next prompt as evidence. + writePtySubmittingLine("\r"); Thread.sleep(200); } + /** + * Writes text that the TUI submits as a line on behalf of the plugin: any + * draft typed in the terminal is part of that line, so the recorder forgets + * it with the write. The reset and the write share the recorder's lock, so + * input arriving at the same moment is neither lost nor mistaken for a + * prefix of the next prompt. + * + * @param text the command line to submit + * @throws IOException if the write fails + */ + private void writePtySubmittingLine(String text) throws IOException { + synchronized (terminalInputLine) { + resetTerminalInputRecording(true); + writePty(text); + } + } + + /** + * Writes the key that answers a choice menu, retires that menu and drops + * the line recorded while it was open, all inside the recorder's lock. The + * write alone is not enough: between it and the retirement a terminal + * writer waiting for the lock could classify the prompt typed right after + * the key against the menu the TUI already dismissed, and swallow it as + * another menu answer. The line typed into the menu — its search filter or + * a text option's answer — is the menu's, not a draft of the next prompt: + * keeping it would prefix that prompt's evidence with it. + * + *

The Enter is the exception when terminal input already answered the + * menu during the worker's pause: the TUI's input line is a fresh prompt + * draft by then, and this Enter submits it, so the line becomes the prompt + * evidence instead of being dropped. + */ + private void writePtyAnsweringMenu(byte[] bytes) throws IOException { + boolean publishes = false; + synchronized (terminalInputLine) { + ChoiceMenuModel menu = openChoiceMenu(); + // The answer workers pause between the arrows and this key: terminal + // input can answer or dismiss the menu in the meantime, and the model + // keeps reporting it until the next poll. This Enter then submits the + // ordinary line — whatever the user typed after that input — instead + // of answering a menu that is already gone, so its text becomes the + // prompt evidence the stop-time lookup follows. + boolean enterOnAnsweredMenu = menu == null + && terminalCompletedMenu != null + && bytes.length == 1 && bytes[0] == '\r'; + String draft = null; + boolean draftExact = true; + long submittedAt = 0L; + if (enterOnAnsweredMenu) { + draft = terminalInputLine.toString().trim(); + draftExact = !terminalHistoryRecall && !terminalCompletionEdited; + submittedAt = System.currentTimeMillis(); + } + writePty(bytes); + if (!enterOnAnsweredMenu) { + discardPickerSearchText(); + } else { + // The line is submitted: it must not prefix the next one, and a + // delayed panel Enter is no longer its submission. + resetTerminalInputRecording(true); + // The evidence is published inside this critical section: a + // terminal writer that takes the lock next would otherwise + // submit a newer prompt first and be overwritten here with this + // older draft, while its later input cutoff stayed — leaving + // both prompt-evidence branches stale. The scan itself is + // queued below, outside the lock. + if (draftExact && isConversationCommand(draft)) { + // /resume and /clear switch the conversation instead of + // writing into it, so their text is no file's prompt; only + // the /resume picker can still be cancelled. + if (isResumeCommand(draft)) { + armPickerSwitch(); + } + conversationSwitchAt = submittedAt; + lastPromptText = null; + promptSubmittedAt = submittedAt; + publishes = true; + } else if (!draftExact) { + publishes = publishPromptEvidence(null, submittedAt); + } else if (!draft.isBlank()) { + publishes = publishPromptEvidence(draft, submittedAt); + } + } + } + if (publishes) { + scheduleIdentityRefresh(); + } + } + + /** + * Publishes the evidence of a submission the recorder observed, unless + * newer evidence is already recorded: a writer that submitted after this + * one must keep its text and time. Call while holding the + * {@link #terminalInputLine} lock. + * + * @param text the submitted text, or {@code null} when it is unknown + * @param sentAt submission time + * @return {@code true} when the evidence was written + */ + private boolean publishPromptEvidence(String text, long sentAt) { + if (sentAt < promptSubmittedAt) return false; + lastPromptText = text; + promptSubmittedAt = sentAt; + return true; + } + + /** @see #writePtyAnsweringMenu(byte[]) */ + private void writePtyAnsweringMenu(String text) throws IOException { + writePtyAnsweringMenu(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + /** * Sends {@code bytes} to the PTY and transitions the model to * {@link SessionLifecycle#READY}. */ private void sendCancelToPty(byte[] bytes) throws IOException { - connector.write(bytes); + writePty(bytes); onClaudeIdle(); } + /** + * Writes bytes to the PTY on behalf of the plugin. + * + *

Plugin writes are marked on the calling thread so that + * {@link PtyTtyConnector#setInputListener} can tell them apart from text + * typed directly into the terminal, which can change the conversation + * (e.g. an in-process {@code /resume}) and invalidates the prompt evidence + * captured by {@link #sendPrompt(String)}. + */ + private void writePty(byte[] bytes) throws IOException { + writePty(bytes, connector); + } + + /** + * Writes bytes to {@code target} — the PTY connector bound to the process + * a send was accepted for, or {@code null} — on behalf of the plugin. + * + *

Writing through the bound connector instead of the current field + * keeps a delayed write out of a replacement process's input line: the old + * connector either accepts the bytes (its process is the one the send was + * meant for) or fails with an {@link IOException} once it is closed. + * + * @param bytes bytes to write + * @param target connector to write to, or {@code null} to drop the write + */ + private void writePty(byte[] bytes, PtyTtyConnector target) throws IOException { + java.util.function.Consumer targetHook = ptyTargetHook; + if (targetHook != null) { + targetHook.accept(target); + } + java.util.function.Consumer hook = ptyWriteHook; + if (hook != null) { + hook.accept(bytes); + return; + } + if (target == null) { + // No PTY for this send (it is still being prepared, or it was + // stopped or replaced): drop the write, as sendPrompt() does. + LOG.fine("[PTY write] dropped, no connector"); + return; + } + boolean previous = pluginPtyWrite.get(); + pluginPtyWrite.set(true); + try { + target.write(bytes); + } finally { + pluginPtyWrite.set(previous); + } + } + + /** + * Package-private for tests: replaces the PTY write, so a test can make the + * write take time or observe when it happens. A {@code null} hook restores + * the real connector. + */ + void simulatePtyWrite(java.util.function.Consumer hook) { + ptyWriteHook = hook; + } + + /** + * Package-private for tests: runs when the PTY child is killed, so a test + * can release a write it made block as a hung process would. + */ + void simulatePtyKill(Runnable hook) { + ptyKillHook = hook; + } + + /** + * Package-private for tests: runs just before the stop path reads the live + * and preserved session claims, so a process exit can be interleaved with + * that snapshot. + */ + void simulateClaimSnapshotHook(Runnable hook) { + claimSnapshotHook = hook; + } + + /** + * Package-private for tests: waits until the background identity scan + * queued by the last prompt send has finished, so a test can decide + * exactly which session files that scan observes. + */ + void simulateAwaitIdentityScan() { + long deadline = System.nanoTime() + + java.util.concurrent.TimeUnit.SECONDS.toNanos(10); + while (identityScanPending.get()) { + if (System.nanoTime() > deadline) { + throw new IllegalStateException("identity scan did not finish"); + } + Thread.onSpinWait(); + } + } + + /** + * Package-private for tests: receives the PTY connector each plugin write + * was bound to, or {@code null} when the write was dropped. Written to + * before the bytes, so a test can tell the accepted process from the + * connector installed by a restart. + */ + void simulatePtyWriteTargets(java.util.function.Consumer hook) { + ptyTargetHook = hook; + } + + /** + * Package-private for tests: attaches {@code replacement} as the current + * PTY connector, as a restart does once the new process is launched. The + * process generation is not changed: it is up to the test to simulate the + * restart itself. + */ + void simulateConnector(PtyTtyConnector replacement) { + connector = replacement; + } + + /** Writes text to the PTY on behalf of the plugin. See {@link #writePty(byte[])}. */ + private void writePty(String text) throws IOException { + writePty(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + /** + * Writes text to the given connector. See + * {@link #writePty(byte[], PtyTtyConnector)}. + */ + private void writePty(String text, PtyTtyConnector target) throws IOException { + writePty(text.getBytes(java.nio.charset.StandardCharsets.UTF_8), target); + } + + /** + * Records input typed in the terminal (called from the PTY connector, on + * the thread that wrote to the PTY). + * + *

Any such input invalidates the prompt evidence captured before it: the + * text may drive Claude's own {@code /resume} picker and switch the + * conversation. A complete line submitted with Enter is the exception — it + * is a prompt written to the conversation on screen, so it becomes the new + * evidence, which is what lets a resume driven entirely from the keyboard + * be followed. A resume command typed there is the other exception: it + * switches the conversation instead of writing to it, and so does an Enter + * that answers the {@code /resume} picker. + * + * @param bytes raw input bytes + * @param now time of the input (epoch millis) + * @return the line submitted with this input, or {@code null} + */ + private String onTerminalInput(byte[] bytes, long now) { + String submitted; + boolean entered; + boolean emptyEnter; + boolean recalled; + boolean menuSelection; + ChoiceMenuModel selectedMenu; + long externalBeforeInput; + synchronized (terminalInputLine) { + externalBeforeInput = externalInputAt; + terminalLineEntered = false; + terminalEnterOnEmptyLine = false; + submitted = appendTerminalInput(bytes); + entered = terminalLineEntered; + terminalLineEntered = false; + emptyEnter = terminalEnterOnEmptyLine; + terminalEnterOnEmptyLine = false; + recalled = terminalRecalledSubmission; + terminalRecalledSubmission = false; + menuSelection = terminalMenuSelection; + terminalMenuSelection = false; + selectedMenu = terminalSelectedMenu; + terminalSelectedMenu = null; + if (menuSelection && isConversationPicker(selectedMenu)) { + // Selecting an entry in the /resume picker is the switch the + // command was waiting for. + confirmPickerSwitch(); + } + if (entered && menuSelection && guiSendAwaitingEnter) { + // The terminal answered a choice menu while the panel's text + // was on the line: that text was the menu's search filter or + // its answer, so the delayed GUI Enter must not publish it as + // prompt evidence. + terminalMenuConsumedGuiSend = true; + } + if (entered && !menuSelection && guiSendAwaitingEnter + && terminalGuiSubmitAt == 0L) { + // An Enter during the GUI send's pause submits the line the + // panel text is on, whatever the recorder holds: the + // reconciliation reset it, so a line that came from a history + // recall or a Tab completion no longer shows there. That + // Enter is the actual submission, and its time is the evidence + // cutoff — the panel's delayed Enter only submits an empty + // line afterwards. Whether keys edited the panel line before + // this Enter is captured now: input after it starts a new + // line and also sets terminalInputSinceGuiSend. + terminalGuiSubmitAt = now; + terminalGuiLineEdited = terminalInputSinceGuiSend; + if (pendingGuiPromptSet && pendingGuiPromptSwitch && !terminalGuiLineEdited) { + // The untouched panel line was submitted by this Enter, so a + // switch command on it did switch. The fact is recorded now, + // not by the panel's delayed completion: a later terminal + // submission replaces the text evidence without undoing the + // switch the conversation already made. + if (pendingGuiPromptResume) { + // A submitted /resume only opened the picker: the + // evidence of the conversation it may fail to leave is + // kept for a cancellation. + armPickerSwitch(); + } + conversationSwitchAt = now; + } + } + } + boolean pickedConversation = menuSelection && isConversationPicker(selectedMenu); + if (entered && !emptyEnter && (!menuSelection || pickedConversation)) { + // An ordinary menu answer only answers a question in the current + // conversation: it submits nothing and does not make the prompt + // evidence stale, so it must not move the external-input cutoff + // past the submission (that would drop the exact-text evidence of + // a prompt that did go to the conversation on screen). Selecting a + // conversation in the picker is different: it changes what the + // process writes. + externalInputAt = now; + } + if (pickedConversation) { + // Enter with the /resume picker open selected a session. That + // changes the conversation that way, and nothing else in the input + // tells which conversation was picked. An ordinary menu answer (a + // permission prompt, the trust-folder confirmation) only answers a + // question in the current conversation, so it must not lift the + // prompt evidence and let the scan adopt an unrelated file. + conversationSwitchAt = now; + } + if (submitted != null) { + if (isConversationCommand(submitted)) { + // A resume command typed in the terminal is not a prompt the + // conversation receives: it switches the conversation, and the + // destination may be a saved one no live tab holds. Its text + // must not become the prompt evidence either — no file holds + // it — so the submission is recorded as unknown-text. + if (isResumeCommand(submitted)) { + // /clear switches at once; a /resume only opens the picker, + // so the evidence of the conversation it may fail to leave + // is kept until an entry is picked or it is cancelled. The + // snapshot predates this input: the external-input cutoff + // is moved below for the submission itself, and restoring + // it to this Enter's time would make the earlier prompt + // look stale and let the scan ignore its text evidence. + armPickerSwitch(conversationSwitchAt, promptSubmittedAt, + externalBeforeInput, lastPromptText); + } + conversationSwitchAt = now; + lastPromptText = null; + } else { + lastPromptText = submitted; + } + promptSubmittedAt = now; + scheduleIdentityRefresh(); + } else if (recalled) { + // The prompt came from the TUI's history (Up/Down): its text is + // unknown to the plugin, but the submission is real. Previous text + // evidence no longer describes what was submitted, and the time + // lets the scan follow the file the process writes next. + lastPromptText = null; + promptSubmittedAt = now; + scheduleIdentityRefresh(); + } + return submitted; + } + + /** + * Consumes {@code bytes} into {@link #terminalInputLine} and returns the + * line when Enter was pressed, or {@code null}. + * + *

Escape sequences (cursor keys, the {@code /resume} picker) and control + * characters are dropped so that only typed text is kept; the cursor keys + * that edit the line (Left/Right/Home/End/Delete) are applied to it. + * Inside a bracketed paste the text is recorded as typed — including its + * newlines, which are part of the prompt rather than submissions. + */ + private String appendTerminalInput(byte[] bytes) { + String submitted = null; + String text = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + // An Esc that was the last byte of the previous write is standalone + // unless this write continues a CSI/SS3 sequence: a terminal sends + // Alt+char as ESC+char in one write, while Esc alone (dismissing the + // autocomplete) arrives on its own and the next keystroke follows in a + // separate write. + boolean carriedEscape = terminalInputEscape == 1; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (terminalEnterSinceGuiSend) { + // The terminal Enter already submitted the panel text during + // the GUI send's pause; everything after it starts a new line, + // so it is not a part of the line that Enter submitted. + terminalDraftAfterEnter = true; + } + // Any input changes the line the TUI holds — including edits that + // leave the recorded buffer unchanged or empty (Backspace, cursor + // keys, Ctrl+U, Tab) — so a GUI submission still waiting for its + // Enter can no longer rely on the text it composed. Enter is + // tracked separately: it submits the line instead of editing it. + if (c == '\r' || c == '\n') { + if (terminalInputPaste) { + // A line break inside a paste is prompt text, not a + // submission, but it still changes the line. + terminalInputSinceGuiSend = true; + } + } else { + terminalInputSinceGuiSend = true; + } + if (c == 0x1b) { + if (carriedEscape && terminalEscapeOnMenu) { + // The Esc carried from the previous write was a standalone + // cancellation of a choice menu, and this Esc begins a new + // sequence: the cancellation is complete, so the picker's + // search text must not become prompt draft. + if (terminalEscapeOnPicker) { + restorePickerSwitch(); + } + discardPickerSearchText(); + } + terminalInputEscape = 1; + terminalEscapeParams.setLength(0); + terminalPasteAfterCR = false; + // A standalone Esc cancels a choice menu (the /resume picker): + // the recorded line then holds the picker's search text, not a + // prompt draft. Cancelling the picker leaves the conversation + // where it was, so the switch it was opened to make is undone. + ChoiceMenuModel escapedMenu = openChoiceMenu(); + // The screen poll publishes the picker a few hundred ms after + // the command: an Esc that reaches it first cancels the very + // menu the pending switch is waiting on, so it counts as one. + boolean unpublishedPicker = escapedMenu == null && pickerSwitchPending; + terminalEscapeOnMenu = escapedMenu != null || unpublishedPicker; + terminalEscapeOnPicker = isConversationPicker(escapedMenu) + || unpublishedPicker; + // A new Esc belongs to this write, so it is not the carried + // standalone one and may start a sequence even right after it. + carriedEscape = false; + } else if (terminalInputEscape == 1) { + // ESC [ starts a CSI sequence, ESC O an SS3 one. Any other + // character ends a two-character escape (ESC x, e.g. Alt+x). + // When the Esc was carried over from the previous write it was + // standalone instead — the TUI uses it to dismiss autocomplete + // — and this write starts the actual input: a real CSI/SS3 + // sequence is written in one piece, so '[' or 'O' typed later + // are ordinary characters too. The character is then processed + // again as input. + if (carriedEscape) { + terminalInputEscape = 0; + i--; + if (terminalEscapeOnMenu) { + // The carried Esc was a standalone cancellation of a + // choice menu: discard the picker's search text so it + // cannot prefix the next prompt. + if (terminalEscapeOnPicker) { + // The cancelled menu was the /resume picker, so the + // conversation never switched and the evidence the + // command replaced comes back. + restorePickerSwitch(); + } + discardPickerSearchText(); + } + } else if (c == '[') { + terminalInputEscape = 2; + } else if (c == 'O') { + terminalInputEscape = 3; + } else { + terminalInputEscape = 0; + } + carriedEscape = false; + } else if (terminalInputEscape == 2) { + // CSI: parameter and intermediate bytes (0x20–0x3f) precede + // the final byte (0x40–0x7e), which ends the sequence. + if (c >= 0x40 && c <= 0x7e) { + terminalInputEscape = 0; + onCsiFinal(c, terminalEscapeParams.toString()); + } else { + terminalEscapeParams.append(c); + } + } else if (terminalInputEscape == 3) { + terminalInputEscape = 0; // SS3: exactly one character after ESC O + if (!terminalInputPaste) applyCursorKey(c); + } else if (terminalInputPaste) { + // Inside a paste every newline is part of the prompt, not a + // submission of the line typed so far. Collect the whole + // textual run — printable characters, tabs (literal text in a + // paste, while the same key typed live completes the input) + // and line breaks, with CRLF normalized to one newline — and + // insert it in a single operation. Inserting one character or + // one line break at a time copies the still-unchanged suffix + // of the draft for every insertion, which makes a large paste + // before long existing text quadratic. The run stops before an + // escape sequence (the end-of-paste marker) or any other + // control character, which are still parsed on their own. + StringBuilder pasted = new StringBuilder(); + boolean afterCR = terminalPasteAfterCR; + int end = i; + while (end < text.length()) { + char next = text.charAt(end); + if (next == '\r') { + pasted.append('\n'); + afterCR = true; + end++; + } else if (next == '\n') { + // The LF of a CRLF pair split across writes is part of + // the same line break. + if (!afterCR) pasted.append('\n'); + afterCR = false; + end++; + } else if (next == '\t' || next >= 0x20) { + pasted.append(next); + afterCR = false; + end++; + } else { + break; + } + } + if (end > i) { + insertTerminalText(pasted); + terminalPasteAfterCR = afterCR; + i = end - 1; + } + } else if (c == '\r' || c == '\n') { + String line = terminalInputLine.toString().trim(); + boolean unknownText = terminalHistoryRecall || terminalCompletionEdited; + ChoiceMenuModel openMenu = openChoiceMenu(); + boolean onMenu = openMenu != null; + // Enter on a choice menu selects an item instead of + // submitting the line, so only the first other Enter is the + // one that submits the line the panel text was written to. + // Later ones submit fresh lines of their own and are recorded + // in full. + boolean firstSubmissionAfterGuiSend = guiSendAwaitingEnter + && !onMenu && terminalGuiSubmitAt == 0L; + // Keys typed during the GUI send's pause edited the line the + // panel text was written to, but the recorder holds none of + // that text: what this Enter submits is unknown even when the + // recorded line is empty (a Backspace on it deletes a + // character of the panel text). + boolean gapEdited = firstSubmissionAfterGuiSend && terminalInputSinceGuiSend; + terminalEnterSinceGuiSend = true; + terminalHistoryRecall = false; + terminalCompletionEdited = false; + terminalInputLine.setLength(0); + terminalInputCursor = 0; + terminalLineEntered = true; + // An Enter on the still-empty line does nothing on the TUI + // unless a choice menu is open: it is neither a prompt nor a + // history recall nor a picker selection, so it must not + // invalidate the evidence of the prompt already submitted. + terminalEnterOnEmptyLine = line.isEmpty() && !unknownText + && !onMenu && !gapEdited; + terminalMenuSelection = onMenu; + terminalSelectedMenu = openMenu; + if (onMenu && !isCheckboxMenu(openMenu)) { + // A numbered menu closes on the selection Enter (a + // checkbox menu only toggles and stays open until Right), + // so the model's stale entry must stop classifying the + // input that follows. + completeTerminalMenu(openMenu); + } + if (unknownText && !onMenu) { + // The line was filled from the TUI history (Up/Down) or + // completed by Tab and may have been edited since: the + // characters recorded here are only a part of the prompt, + // so its text is unknown and an in-process resume must be + // followed by file times. With a menu open the Enter + // answers it instead: the recalled text is the picker's + // search filter, which submits no prompt and must not + // replace the evidence of the prompt already submitted. + terminalRecalledSubmission = true; + } else if (!onMenu && (gapEdited || !line.isEmpty())) { + // With a choice menu open Enter selects an item instead of + // submitting the line: what was typed there is the picker's + // search filter, not a prompt. + if (firstSubmissionAfterGuiSend && !terminalGuiTextAnsweredMenu) { + // A prompt sent from the panel is already written on + // the TUI's line: this Enter submits it together with + // the recorded text, which is only a part of the + // prompt. Publishing it as the evidence would let an + // identity scan match another conversation's file for + // the partial text before the panel's Enter is sent. + // When that text answered the menu on arrival, the + // line was cleared and the recorder holds it in full. + terminalRecalledSubmission = true; + } else { + // Anything typed after that submission starts a new + // line the recorder has in full, so it is known text. + submitted = line; + } + } + } else if (c == 0x7f || c == 0x08) { + deleteTerminalCharBeforeCursor(); + } else if (c == 0x03 || c == 0x15) { // Ctrl+C, Ctrl+U + terminalInputLine.setLength(0); + terminalInputCursor = 0; + // Clearing the input also discards a recalled or completed + // prompt, so what is typed next is known in full again. + terminalHistoryRecall = false; + terminalCompletionEdited = false; + } else if (c == '\t') { + // A live Tab makes the TUI complete the input from its file or + // command suggestions; the inserted text never reaches this + // parser, so the line recorded so far is only a prefix. While a + // choice menu is open Tab belongs to the menu ("Tab to amend") + // and Enter selects an item instead of submitting a prompt, so + // the menu context is not completion evidence. + if (openChoiceMenu() == null) { + terminalCompletionEdited = true; + } + } else if (c >= 0x20) { + // While a choice menu is open, a printed digit selects an item + // immediately (see writePtyAnswer) and never becomes part of a + // prompt: recording it would prefix the next prompt's evidence + // with the selection and make the actual text unmatchable. + ChoiceMenuModel openMenu = openChoiceMenu(); + if (openMenu != null && c >= '0' && c <= '9') { + if (digitSelectionDismissesMenu(openMenu, c)) { + // The digit answers the menu and closes it, so nothing + // typed into it is part of the next prompt: the search + // text accumulated while it was open must not prefix + // that prompt's evidence (see the Right-submit path). + discardPickerSearchText(); + } else { + int activated = activatedTextEditor(openMenu, c); + if (activated >= 0) { + // The digit moved the cursor into the menu's text + // editor; the poll reports it only later, so the + // digits typed meanwhile are text for that editor, + // not selections. + trackMenuFocus(openMenu, activated); + } + } + } else { + insertTerminalChar(c); + } + } + } + return submitted; + } + + /** Set when {@link #appendTerminalInput(byte[])} saw Enter, cleared by the caller. */ + private boolean terminalLineEntered; + + /** + * Set when that Enter closed a line that was still empty and no choice menu + * was open, i.e. it acted on nothing: cleared by the caller like + * {@link #terminalLineEntered}. + */ + private boolean terminalEnterOnEmptyLine; + + /** Inserts {@code c} at the cursor and advances it. */ + private void insertTerminalChar(char c) { + int at = Math.min(terminalInputCursor, terminalInputLine.length()); + terminalInputLine.insert(at, c); + terminalInputCursor = at + 1; + } + + /** Inserts {@code text} at the cursor in one operation and advances it. */ + private void insertTerminalText(CharSequence text) { + int at = Math.min(terminalInputCursor, terminalInputLine.length()); + terminalInputLine.insert(at, text); + terminalInputCursor = at + text.length(); + } + + /** + * Applies a horizontal cursor key (Left/Right/Home/End) to the line being + * recorded, so that text typed after it lands where the user sees it. + * Left/Right step over whole Unicode characters: a supplementary character + * occupies two UTF-16 code units, and stopping between them would let a + * later edit split the pair. + * + * @param key CSI/SS3 final byte: {@code D}, {@code C}, {@code H} or {@code F} + */ + private void applyCursorKey(char key) { + switch (key) { + case 'D' -> { + int at = Math.min(terminalInputCursor, terminalInputLine.length()); + terminalInputCursor = + at <= 0 ? 0 : terminalInputLine.offsetByCodePoints(at, -1); + } + case 'C' -> { + ChoiceMenuModel menu = openChoiceMenu(); + if (menu != null && isCheckboxMenu(menu)) { + // Right submits a checkbox menu (the answer protocol the + // menu panel writes): the menu is dismissed, so the text + // typed into it — an option's free-text answer — must not + // prefix the next prompt. + discardPickerSearchText(); + return; + } + int at = Math.min(terminalInputCursor, terminalInputLine.length()); + terminalInputCursor = at >= terminalInputLine.length() + ? terminalInputLine.length() + : terminalInputLine.offsetByCodePoints(at, 1); + } + case 'H' -> terminalInputCursor = 0; + case 'F' -> terminalInputCursor = terminalInputLine.length(); + case 'A', 'B' -> { + // Up/Down recall a prompt from the TUI history into its input; + // the text never reaches this parser. Remember that whatever is + // submitted next was not typed here. While a choice menu is + // open the same keys move its cursor instead — the line stays + // empty too, but Enter selects a menu item rather than a + // recalled prompt, so it must not be recorded as a submission. + ChoiceMenuModel menu = openChoiceMenu(); + if (menu != null) { + // The arrow moves the menu's cursor and the poll reports it + // only later. Track where it lands: leaving a text editor + // lets the next digit select an item again, and entering + // one makes the digits text for it. + int size = menu.options().size(); + if (size > 0) { + int focused = focusedMenuOption(menu); + if (focused < 0 || focused >= size) focused = 0; + trackMenuFocus(menu, Math.floorMod(focused + (key == 'A' ? -1 : 1), size)); + } + } else if (terminalInputLine.isEmpty()) { + terminalHistoryRecall = true; + } + } + default -> { /* other keys do not edit the line */ } + } + } + + /** + * Removes the whole character before the cursor (the Backspace key), + * including both halves of a supplementary Unicode character. + */ + private void deleteTerminalCharBeforeCursor() { + int end = Math.min(terminalInputCursor, terminalInputLine.length()); + if (end <= 0) return; + int start = terminalInputLine.offsetByCodePoints(end, -1); + terminalInputLine.delete(start, end); + terminalInputCursor = start; + } + + /** + * Deletes the whole character under the cursor (the Delete key), including + * both halves of a supplementary Unicode character. + */ + private void deleteTerminalCharAtCursor() { + int at = Math.min(terminalInputCursor, terminalInputLine.length()); + if (at >= terminalInputLine.length()) return; + int end = terminalInputLine.offsetByCodePoints(at, 1); + terminalInputLine.delete(at, end); + terminalInputCursor = at; + } + + /** + * Handles the final byte of a CSI sequence: the bracketed-paste markers + * {@code ESC [ 200 ~} / {@code ESC [ 201 ~} switch paste recording on and + * off, cursor keys edit the recorded line, everything else is dropped. + * + * @param finalByte final byte of the sequence + * @param params parameter bytes collected before it + */ + private void onCsiFinal(char finalByte, String params) { + if (finalByte == '~') { + if ("200".equals(params)) { + terminalInputPaste = true; + terminalPasteAfterCR = false; + } else if ("201".equals(params)) { + terminalInputPaste = false; + terminalPasteAfterCR = false; + } else if (!terminalInputPaste) { + if ("3".equals(params)) { + deleteTerminalCharAtCursor(); + } else if ("1".equals(params) || "7".equals(params)) { + applyCursorKey('H'); + } else if ("4".equals(params) || "8".equals(params)) { + applyCursorKey('F'); + } + } + } else if (!terminalInputPaste) { + applyCursorKey(finalByte); // Left/Right/Home/End; others are ignored + } + } + + /** Package-private for tests: simulates typing in the terminal at {@code millis}. */ + void simulateExternalInput(long millis) { + externalInputAt = millis; + } + + /** + * Package-private for tests: simulates raw terminal input arriving at + * {@code millis}. + * + * @return the line submitted by this input, or {@code null} + */ + String simulateTerminalInput(byte[] bytes, long millis) { + return onTerminalInput(bytes, millis); + } + /** * Sends a single SIGWINCH pair (shrink by one row, then restore) to force * Claude CLI to fully redraw the TUI. Used after model discovery Esc diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java index d5f9d0aa..b15397f6 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java @@ -82,6 +82,12 @@ public ClaudeProcess() {} /** UUID of the active OpenAI proxy session; {@code null} when not using OpenAI proxy. */ private volatile String openAIProxyUuid; + /** + * Effective {@code CLAUDE_CONFIG_DIR} used by the running process (never + * {@code null} while a process is running). Defaults to {@code ~/.claude}. + */ + private volatile Path effectiveConfigDir; + /** * Starts a Claude CLI PTY process in the given working directory using * the Default profile (no extra env vars injected). @@ -158,6 +164,7 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl String executable = ClaudeCodePreferences.resolveClaudeExecutable(); Map env = buildEnv(profile, ClaudeCodePreferences.getProfilesDir()); + effectiveConfigDir = configDirFrom(env); // OpenAI proxy: register session and inject ANTHROPIC_BASE_URL pointing to internal proxy if (profile != null @@ -333,10 +340,7 @@ void appendSessionFlags(List cmd, String workingDir, Map } if (mode == SessionMode.CONTINUE_LAST) { - String configDirStr = env.get("CLAUDE_CONFIG_DIR"); - Path claudeConfigDir = configDirStr != null - ? Path.of(configDirStr) - : Path.of(System.getProperty("user.home"), ".claude"); + Path claudeConfigDir = configDirFrom(env); if (ClaudeSessionStore.hasAnySessions(Path.of(workingDir), claudeConfigDir)) { cmd.add("--continue"); LOG.fine("Session mode: --continue (existing session found)"); @@ -822,6 +826,42 @@ static Map buildEnv(ClaudeProfile profile, java.nio.file.Path pr return env; } + /** + * Resolves the effective {@code CLAUDE_CONFIG_DIR} from an environment map. + * + * @param env environment map used for the child process + * @return the configured directory, or {@code ~/.claude} when not overridden + */ + static java.nio.file.Path configDirFrom(Map env) { + String configDirStr = env.get("CLAUDE_CONFIG_DIR"); + return configDirStr != null && !configDirStr.isBlank() + ? java.nio.file.Path.of(configDirStr) + : java.nio.file.Path.of(System.getProperty("user.home"), ".claude"); + } + + /** + * Resolves the config dir the given profile will use once started. + * + *

This mirrors {@link #buildEnv} (including profile extra env vars), so it + * can be used to inspect the session store before the process is launched. + * + * @param profile connection profile; {@code null} for Default + * @param profilesDir base directory for profile config dirs + * @return effective config dir of a session started with this profile + */ + public static java.nio.file.Path resolveConfigDir(ClaudeProfile profile, java.nio.file.Path profilesDir) { + return configDirFrom(buildEnv(profile, profilesDir)); + } + + /** + * Returns the effective config dir of the current (or most recent) process. + * + * @return resolved config dir, or {@code null} if no process was started yet + */ + public java.nio.file.Path getConfigDir() { + return effectiveConfigDir; + } + private static void ensureLocalhostInNoProxy(Map env) { // Normalize lowercase no_proxy → NO_PROXY (Linux systems may use either) if (!env.containsKey("NO_PROXY") && env.containsKey("no_proxy")) { diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index f416ead4..1e1679d1 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -12,7 +12,9 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.logging.Logger; import java.util.stream.Stream; @@ -30,6 +32,14 @@ public final class ClaudeSessionStore { private static final Logger LOG = Logger.getLogger(ClaudeSessionStore.class.getName()); private static final ObjectMapper MAPPER = new ObjectMapper(); + /** + * Size of the block read at a time when scanning a session file backwards + * for the prompt a running process just appended. The scan stops at the + * first record older than the cutoff, so the whole file is read only when + * the conversation is entirely newer than it. + */ + private static final int PROMPT_EVIDENCE_BLOCK_BYTES = 512 * 1024; + private ClaudeSessionStore() {} // ------------------------------------------------------------------------- @@ -69,6 +79,17 @@ static Path sessionsDir(Path workingDir, Path claudeConfigDir) { return configDir.resolve("projects").resolve(computeHash(workingDir)); } + /** + * Returns the session directory for diagnostics/logging. + * + * @param workingDir working directory, or {@code null} + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @return session directory, or {@code ""} when the working dir is missing + */ + public static String sessionDirForLog(Path workingDir, Path claudeConfigDir) { + return workingDir != null ? sessionsDir(workingDir, claudeConfigDir).toString() : ""; + } + // ------------------------------------------------------------------------- // Existence check // ------------------------------------------------------------------------- @@ -94,6 +115,492 @@ public static boolean hasAnySessions(Path workingDir, Path claudeConfigDir) { } } + // ------------------------------------------------------------------------- + // Session identity helpers + // ------------------------------------------------------------------------- + + /** + * Returns the IDs of all session JSONL files for the given working directory. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @return mutable set of session IDs (file names without {@code .jsonl}) + */ + public static Set listSessionIds(Path workingDir, Path claudeConfigDir) { + Path dir = sessionsDir(workingDir, claudeConfigDir); + Set ids = new LinkedHashSet<>(); + if (!Files.isDirectory(dir)) { + return ids; + } + try (Stream files = Files.list(dir)) { + files.map(p -> p.getFileName().toString()) + .filter(name -> name.endsWith(".jsonl")) + .forEach(name -> ids.add(name.substring(0, name.length() - 6))); + } catch (IOException e) { + LOG.fine("Could not list session ids in " + dir + ": " + e.getMessage()); + } + return ids; + } + + /** + * Returns the ID of the most recently modified session JSONL file. + * + *

Uses only file modification times (no parsing) — this mirrors the session + * that {@code claude --continue} resumes. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @return session ID, or {@code null} if no session file exists + */ + public static String findLatestSessionId(Path workingDir, Path claudeConfigDir) { + Path newest = newestSessionFile(sessionsDir(workingDir, claudeConfigDir), null, 0); + return newest != null ? sessionIdOf(newest) : null; + } + + /** + * Returns the ID of a session file created since a launch: the newest JSONL file + * whose ID is not in {@code excludeIds} and whose mtime is at least + * {@code minMtimeMillis}. If no such file matches the mtime, falls back to the + * newest file not in {@code excludeIds}. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param excludeIds session IDs that already existed before the launch (may be {@code null}) + * @param minMtimeMillis earliest acceptable file modification time (epoch millis) + * @return session ID, or {@code null} if no candidate exists + */ + public static String findSessionIdCreatedAfter(Path workingDir, Path claudeConfigDir, + Set excludeIds, long minMtimeMillis) { + Path dir = sessionsDir(workingDir, claudeConfigDir); + Set exclude = excludeIds != null ? excludeIds : Set.of(); + Path newest = newestSessionFile(dir, exclude, minMtimeMillis); + if (newest == null) { + newest = newestSessionFile(dir, exclude, 0); + } + return newest != null ? sessionIdOf(newest) : null; + } + + /** + * Returns the ID of the session file modified most recently, considering only + * files whose mtime is at least {@code sinceMillis} and whose ID is not in + * {@code excludeIds}. + * + *

Unlike {@link #findSessionIdCreatedAfter} there is no fallback to older + * files: this is used to detect which conversation a running process is + * currently writing after it may have switched sessions in-process. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sinceMillis earliest acceptable file modification time (epoch millis) + * @param excludeIds session IDs to ignore (e.g. owned by other tabs); may be {@code null} + * @return session ID, or {@code null} if no file was modified since {@code sinceMillis} + */ + public static String findLatestSessionIdWrittenSince(Path workingDir, Path claudeConfigDir, + long sinceMillis, Set excludeIds) { + Path newest = newestSessionFile( + sessionsDir(workingDir, claudeConfigDir), excludeIds, sinceMillis); + return newest != null ? sessionIdOf(newest) : null; + } + + /** + * Returns up to {@code limit} IDs of session files modified at or after + * {@code sinceMillis}, newest first, skipping {@code excludeIds}. + * + *

Used to look for this tab's own conversation among the files written + * since its prompt: another session can be newer than the one this process + * is writing, so a single newest-file lookup is not enough. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sinceMillis earliest acceptable file modification time (epoch millis) + * @param excludeIds session IDs to ignore (e.g. owned by other tabs); may be {@code null} + * @param limit maximum number of IDs to return + * @return session IDs, newest first; empty if none match + */ + public static List listSessionIdsWrittenSince(Path workingDir, Path claudeConfigDir, + long sinceMillis, Set excludeIds, + int limit) { + return listSessionIdsWrittenSince(workingDir, claudeConfigDir, sinceMillis, + excludeIds, null, limit); + } + + /** + * Returns up to {@code limit} IDs of session files modified at or after + * {@code sinceMillis}, newest first, skipping {@code excludeIds}. + * + *

When {@code includeIds} is non-{@code null}, only those sessions are + * considered before the limit is applied. A caller that is looking + * for a specific known conversation (e.g. one another tab is writing) must + * not let newer unrelated files fill the limited list and hide it. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sinceMillis earliest acceptable file modification time (epoch millis) + * @param excludeIds session IDs to ignore; may be {@code null} + * @param includeIds session IDs to consider, or {@code null} for all + * @param limit maximum number of IDs to return + * @return session IDs, newest first; empty if none match + */ + public static List listSessionIdsWrittenSince(Path workingDir, Path claudeConfigDir, + long sinceMillis, Set excludeIds, + Set includeIds, int limit) { + if (limit <= 0) return List.of(); + Path dir = sessionsDir(workingDir, claudeConfigDir); + if (!Files.isDirectory(dir)) return List.of(); + List> found = new ArrayList<>(); + try (Stream files = Files.list(dir)) { + for (Path file : (Iterable) files::iterator) { + String name = file.getFileName().toString(); + if (!name.endsWith(".jsonl")) continue; + String id = sessionIdOf(file); + if (includeIds != null && !includeIds.contains(id)) continue; + if (excludeIds != null && excludeIds.contains(id)) continue; + long mtime; + try { + mtime = Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + continue; + } + if (mtime < sinceMillis) continue; + found.add(java.util.Map.entry(mtime, id)); + } + } catch (IOException e) { + LOG.fine("Could not scan sessions in " + dir + ": " + e.getMessage()); + return List.of(); + } + found.sort(java.util.Map.Entry.comparingByKey().reversed()); + List ids = new ArrayList<>(); + for (java.util.Map.Entry entry : found) { + ids.add(entry.getValue()); + if (ids.size() >= limit) break; + } + return ids; + } + + /** + * Returns when the session's conversation file was last written. + * + * @param workingDir the project directory the session belongs to + * @param claudeConfigDir the Claude configuration directory + * @param sessionId the session ID + * @return the last-modified time in epoch millis, or {@code 0} when the file + * does not exist or cannot be read + */ + public static long sessionFileModified(Path workingDir, Path claudeConfigDir, + String sessionId) { + Path file = sessionsDir(workingDir, claudeConfigDir) + .resolve(sessionId + ".jsonl"); + long modified = 0L; + try { + modified = Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + LOG.fine("Could not read the time of " + file + ": " + e.getMessage()); + } + return modified; + } + + /** + * Returns whether the session file contains a user message with + * {@code prompt} whose timestamp is at or after {@code sinceMillis}. + * + *

This is the evidence that a running process — and not a sibling tab + * writing its own conversation — is the one appending to the file: a + * conversation resumed in-process contains the prompt this tab just + * submitted, while another tab's turn contains only that tab's messages. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sessionId session ID (file name without {@code .jsonl}) + * @param sinceMillis earliest acceptable message timestamp (epoch millis) + * @param prompt prompt text submitted by this tab + * @return {@code true} if such a message exists; {@code false} otherwise + */ + public static boolean containsUserPromptSince(Path workingDir, Path claudeConfigDir, + String sessionId, long sinceMillis, + String prompt) { + if (prompt == null || prompt.isBlank()) return false; + String wanted = prompt.trim(); + if (wanted.isEmpty()) return false; + return containsUserRecordSince(workingDir, claudeConfigDir, sessionId, sinceMillis, + line -> isUserMessageWith(line, sinceMillis, wanted)); + } + + /** + * Returns whether the session file contains a user message written at or + * after {@code sinceMillis}, whatever its text. + * + *

Used when the submitted prompt has no known text (it was recalled from + * the TUI history): a user record written after that submission is evidence + * that a prompt went into the file, while another tab's turn only appends + * assistant records, so a newer file time alone is not. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sessionId session ID (file name without {@code .jsonl}) + * @param sinceMillis earliest acceptable message timestamp (epoch millis) + * @return {@code true} if such a message exists; {@code false} otherwise + */ + public static boolean containsUserMessageSince(Path workingDir, Path claudeConfigDir, + String sessionId, long sinceMillis) { + return containsUserRecordSince(workingDir, claudeConfigDir, sessionId, sinceMillis, + line -> isUserMessageSince(line, sinceMillis)); + } + + private static boolean containsUserRecordSince(Path workingDir, Path claudeConfigDir, + String sessionId, long sinceMillis, + java.util.function.Predicate match) { + if (sessionId == null) return false; + Path file = sessionsDir(workingDir, claudeConfigDir).resolve(sessionId + ".jsonl"); + if (!Files.isRegularFile(file)) return false; + try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(file.toFile(), "r")) { + // Read backwards in blocks: the newest records are the most likely + // match, and a long turn can push the prompt arbitrarily far from + // the end of the file. The scan stops as soon as a conversation + // record older than the cutoff is reached, since those records are + // appended in time order. Rename titles are not: they are appended + // after a read of the file and may carry a timestamp sampled + // before records already in it, so the scan goes past them. + long pos = raf.length(); + // Bytes read so far that belong to a record cut by the block + // boundaries, kept as separate chunks in file order: a record + // spanning many blocks is never recopied while it is being found. + java.util.ArrayDeque pending = new java.util.ArrayDeque<>(); + byte[] buffer = new byte[PROMPT_EVIDENCE_BLOCK_BYTES]; + while (pos > 0) { + int size = (int) Math.min(PROMPT_EVIDENCE_BLOCK_BYTES, pos); + long start = pos - size; + raf.seek(start); + raf.readFully(buffer, 0, size); + pos = start; + + int newline = indexOf(buffer, size, (byte) '\n'); + if (pos > 0 && newline < 0) { + // The record fills this whole block and continues earlier: + // keep the bytes and look for its start in the next block. + pending.addFirst(java.util.Arrays.copyOf(buffer, size)); + continue; + } + + // Assemble the complete lines once: the part of this block after + // its first newline (or all of it when the file starts here), + // followed by the chunks already collected. + int firstLine = pos > 0 ? newline + 1 : 0; + java.io.ByteArrayOutputStream joined = new java.io.ByteArrayOutputStream( + size + pending.stream().mapToInt(chunk -> chunk.length).sum()); + joined.write(buffer, firstLine, size - firstLine); + for (byte[] chunk : pending) joined.write(chunk, 0, chunk.length); + pending.clear(); + if (pos > 0 && newline > 0) { + // The first line is cut in the middle: its beginning is in + // the block read next, so keep its bytes for that block. + pending.addFirst(java.util.Arrays.copyOf(buffer, newline)); + } + + String text = joined.toString(StandardCharsets.UTF_8); + String[] lines = text.split("\n", -1); + for (int i = lines.length - 1; i >= 0; i--) { + if (match.test(lines[i])) return true; + if (!isTitleRecord(lines[i]) && isOlderThan(lines[i], sinceMillis)) { + return false; + } + } + } + return false; + } catch (IOException | RuntimeException e) { + LOG.fine("Could not read session " + file + ": " + e.getMessage()); + return false; + } + } + + /** Returns the index of the first occurrence of {@code b} in the first {@code length} bytes, or {@code -1}. */ + private static int indexOf(byte[] bytes, int length, byte b) { + for (int i = 0; i < length; i++) { + if (bytes[i] == b) return i; + } + return -1; + } + + /** + * Returns whether the JSONL {@code line} is a session rename title record. + * + *

Such a record is appended after a read of the file, so it can be dated + * before records already in it; its position does not prove that the + * records above it are older. + */ + private static boolean isTitleRecord(String line) { + if (line.isBlank() || !line.contains("custom-title")) return false; + try { + return "custom-title".equals(MAPPER.readTree(line).path("type").asText()); + } catch (Exception e) { + return false; + } + } + + /** + * Returns whether the JSONL {@code line} is a conversation record dated + * before {@code cutoffMillis}. Those records are appended in time order, so + * everything before such a line is older still and the backward scan can + * stop. + */ + private static boolean isOlderThan(String line, long cutoffMillis) { + if (line.isBlank()) return false; + try { + JsonNode node = MAPPER.readTree(line); + JsonNode tsNode = node.get("timestamp"); + if (tsNode == null || !tsNode.isTextual()) return false; + return Instant.parse(tsNode.asText()).toEpochMilli() < cutoffMillis; + } catch (Exception e) { + return false; + } + } + + /** Returns whether the JSONL {@code line} is a user message dated at or after {@code sinceMillis}. */ + private static boolean isUserMessageSince(String line, long sinceMillis) { + if (line.isBlank()) return false; + try { + JsonNode node = MAPPER.readTree(line); + if (!"user".equals(node.path("type").asText())) return false; + JsonNode tsNode = node.get("timestamp"); + if (tsNode == null || !tsNode.isTextual()) return false; + if (Instant.parse(tsNode.asText()).toEpochMilli() < sinceMillis) return false; + return isHumanUserMessage(node); + } catch (Exception e) { + return false; + } + } + + /** + * Returns whether a {@code type: "user"} record was typed by the human. + * + *

Claude also stores tool results as {@code type: "user"} records while + * it works, and writes system-injected messages with {@code isMeta}; neither + * is a prompt, so neither is evidence that this process submitted one. + */ + private static boolean isHumanUserMessage(JsonNode node) { + if (node.path("isMeta").asBoolean(false)) return false; + JsonNode content = node.path("message").get("content"); + if (content == null) return false; + if (content.isTextual()) return !content.asText().isBlank(); + if (!content.isArray()) return false; + for (JsonNode block : content) { + if ("text".equals(block.path("type").asText())) return true; + } + return false; + } + + /** Returns whether the JSONL {@code line} is a user message carrying {@code prompt}. */ + private static boolean isUserMessageWith(String line, long sinceMillis, String prompt) { + if (line.isBlank()) return false; + try { + JsonNode node = MAPPER.readTree(line); + if (!"user".equals(node.path("type").asText())) return false; + // A system-injected message can carry the same text as a prompt: + // it is not something this tab typed, so matching it would make an + // unrelated conversation look like the destination. + if (!isHumanUserMessage(node)) return false; + JsonNode tsNode = node.get("timestamp"); + if (tsNode == null || !tsNode.isTextual()) return false; + if (Instant.parse(tsNode.asText()).toEpochMilli() < sinceMillis) return false; + JsonNode content = node.path("message").get("content"); + if (content == null) return false; + if (content.isTextual()) return isSamePrompt(content.asText(), prompt); + if (content.isArray()) { + // The whole textual message is the prompt: a message split + // into several blocks is longer than any one of them, and + // accepting a single matching block would make a longer + // unrelated message look like this tab's prompt. + StringBuilder joined = new StringBuilder(); + for (JsonNode block : content) { + String text = textOfBlock(block); + if (text == null) continue; + if (joined.length() > 0) joined.append('\n'); + joined.append(text); + } + return isSamePrompt(joined.toString(), prompt); + } + return false; + } catch (Exception e) { + return false; + } + } + + /** Returns the text of a content block, or {@code null} for non-text blocks. */ + private static String textOfBlock(JsonNode block) { + if (block.isTextual()) return block.asText(); + if (block.isObject() && "text".equals(block.path("type").asText())) { + return block.path("text").asText(); + } + return null; + } + + /** + * Compares a stored message with the submitted prompt. The whole message + * must match: a shorter prompt such as {@code yes} must not be treated as + * evidence for an unrelated message that merely contains it. + */ + private static boolean isSamePrompt(String text, String prompt) { + return !text.isBlank() && text.trim().equals(prompt); + } + + private static Path newestSessionFile(Path dir, Set excludeIds, long minMtimeMillis) { + if (!Files.isDirectory(dir)) { + return null; + } + Path newest = null; + long newestMtime = Long.MIN_VALUE; + try (Stream files = Files.list(dir)) { + for (Path file : (Iterable) files::iterator) { + String name = file.getFileName().toString(); + if (!name.endsWith(".jsonl")) continue; + String id = sessionIdOf(file); + if (excludeIds != null && excludeIds.contains(id)) continue; + long mtime; + try { + mtime = Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + continue; + } + if (mtime < minMtimeMillis) continue; + if (newest == null || mtime > newestMtime) { + newest = file; + newestMtime = mtime; + } + } + } catch (IOException e) { + LOG.fine("Could not scan sessions in " + dir + ": " + e.getMessage()); + } + return newest; + } + + private static String sessionIdOf(Path file) { + String name = file.getFileName().toString(); + return name.endsWith(".jsonl") ? name.substring(0, name.length() - 6) : name; + } + + /** + * Returns the parsed session with the given ID. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sessionId session ID (file name without {@code .jsonl}) + * @return the session, or {@code null} if the file is missing or unparseable + */ + public static SavedSession findSession(Path workingDir, Path claudeConfigDir, String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return null; + } + Path file = sessionsDir(workingDir, claudeConfigDir).resolve(sessionId + ".jsonl"); + if (!Files.exists(file)) { + return null; + } + try { + return parseSessionFile(file); + } catch (Exception e) { + LOG.fine("Could not parse session file " + file + ": " + e.getMessage()); + return null; + } + } + // ------------------------------------------------------------------------- // List // ------------------------------------------------------------------------- @@ -288,6 +795,10 @@ public static void renameSession(Path workingDir, Path claudeConfigDir, if (!Files.exists(file)) { throw new IOException("Session file not found: " + file); } + // Read first and sample the clock afterwards: the title is appended + // last, so it must not be dated before the records it follows, or a + // reader that relies on the append order stops before them. + String existing = Files.readString(file, StandardCharsets.UTF_8); String timestamp = Instant.now().toString(); String line = MAPPER.writeValueAsString( MAPPER.createObjectNode() @@ -297,7 +808,6 @@ public static void renameSession(Path workingDir, Path claudeConfigDir, .put("timestamp", timestamp) ); // Append with newline - String existing = Files.readString(file, StandardCharsets.UTF_8); String newContent = existing.endsWith("\n") ? existing + line + "\n" : existing + "\n" + line + "\n"; Files.writeString(file, newContent, StandardCharsets.UTF_8); LOG.fine("Renamed session " + sessionId + " to '" + newName + "'"); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java b/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java index 7a870694..4dc00956 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java @@ -34,6 +34,9 @@ public final class PtyTtyConnector implements TtyConnector { private final PtyProcess process; private final InputStreamReader reader; + /** Time source for the submission timestamps passed to the input listener. */ + private final java.util.function.LongSupplier clock; + private String tag = ""; /** Accumulates raw chars between newlines for line-based detection. */ @@ -42,13 +45,41 @@ public final class PtyTtyConnector implements TtyConnector { /** Invoked with each stripped line; {@code null} means no listener. */ private volatile Consumer lineListener; + /** Invoked with every byte block written to the PTY and the time the write + * started; {@code null} means no listener. */ + private volatile java.util.function.BiConsumer inputListener; + + /** + * Lock held while bytes are forwarded to the child and the input listener + * is notified; {@code null} means no serialization. The controller passes + * the lock it also holds while it writes the GUI prompt's Enter, so + * terminal input cannot reach the child on one side of that Enter while + * being recorded on the other. + */ + private volatile Object inputLock; + + /** + * Sets the lock that serializes forwarding with the plugin's own writes. + * + * @param lock the lock, or {@code null} for none + */ + public void setInputLock(Object lock) { + this.inputLock = lock; + } + /** * Creates a connector for the given PTY process. * * @param process a running PTY process */ public PtyTtyConnector(PtyProcess process) { + this(process, System::currentTimeMillis); + } + + /** Package-private for tests: injects the clock used for terminal submissions. */ + PtyTtyConnector(PtyProcess process, java.util.function.LongSupplier clock) { this.process = process; + this.clock = clock; this.reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8); } @@ -64,6 +95,21 @@ public void setLineListener(Consumer listener) { this.lineListener = listener; } + /** + * Sets a listener that receives every block of bytes written to the PTY — + * both plugin-sent input and text typed in the terminal — together with the + * time the write started. + * + *

The timestamp is sampled before the bytes are forwarded so that a + * child which appends the submitted prompt to its session file right after + * reading them cannot produce a record later than the reported time. + * + * @param listener the listener, or {@code null} to clear + */ + public void setInputListener(java.util.function.BiConsumer listener) { + this.inputListener = listener; + } + /** * Sets a session tag used as a log prefix for debugging. * @@ -107,8 +153,32 @@ public int read(char[] buf, int offset, int length) throws IOException { @Override public void write(byte[] bytes) throws IOException { + // Terminal input and the plugin's own writes (in particular the GUI + // prompt's Enter) must reach the child in the same order in which they + // are recorded: forwarding bytes before the GUI Enter while notifying + // the listener only after the plugin reset its recorder would leave + // those bytes behind as the draft of the next line. The controller + // takes the same lock for the GUI Enter. + Object lock = inputLock; + if (lock == null) { + forward(bytes); + return; + } + synchronized (lock) { + forward(bytes); + } + } + + private void forward(byte[] bytes) throws IOException { + // Sampled before the bytes are forwarded: the child can append the + // submitted prompt to its session file as soon as it reads them, so a + // timestamp taken after the write/listener could be later than the + // record the controller uses as evidence. + long sentAt = clock.getAsLong(); process.getOutputStream().write(bytes); process.getOutputStream().flush(); + java.util.function.BiConsumer l = inputListener; + if (l != null) l.accept(bytes, sentAt); } @Override diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java index bb943274..42797d95 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java @@ -84,6 +84,12 @@ public final class ClaudePromptPanel extends JPanel { private final Runnable onShowSessionStatistics; /** Returns whether Session Statistics is available for the current session's connection type. */ private final Supplier sessionStatisticsAvailable; + /** + * Returns whether a prompt can be delivered right now. The send key must + * respect it: while a session is still starting, {@code onSend} would drop + * the text, so the field keeps it instead. + */ + private volatile Supplier sendAvailable = () -> true; /** Current position in the history list; {@code -1} = newest (empty field). */ private int historyIndex = -1; @@ -243,6 +249,19 @@ public void setReadyState(boolean ready) { cancelButton.setEnabled(!ready); } + /** + * Sets the predicate that decides whether a sent prompt can be delivered. + * + *

When it returns {@code false}, {@code doSend()} leaves the input text + * untouched instead of clearing it for a prompt that would be dropped — + * e.g. while the session process is still starting. + * + * @param sendAvailable the availability predicate, or {@code null} to always allow + */ + public void setSendAvailable(Supplier sendAvailable) { + this.sendAvailable = sendAvailable != null ? sendAvailable : () -> true; + } + /** * Requests keyboard focus on the text input area. */ @@ -321,6 +340,9 @@ public void keyPressed(KeyEvent e) { private void doSend() { String text = inputArea.getText(); if (text.isEmpty()) return; + // Nothing can consume the prompt yet (e.g. the session is still + // starting): keep the text so the user does not lose it. + if (!Boolean.TRUE.equals(sendAvailable.get())) return; inputArea.setText(""); inputArea.requestFocusInWindow(); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java index 3f07ff4b..8e1217e3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java @@ -407,6 +407,46 @@ private void reloadSessionModePanel() { } } + /** + * Reloads the session list of the embedded mode panel without changing the + * selected mode. Called after a session stops so a name saved by the + * Save & Switch dialog is visible immediately. + * + *

The transcript files are parsed on a worker (the reload runs while the + * tab tears down its chat UI); only the table update happens on the EDT. + */ + public void refreshSessionList() { + if (sessionModePanel == null) return; + File dir = resolveDir(); + java.nio.file.Path workingDir = dir != null ? dir.toPath() : null; + java.nio.file.Path configDir = resolveClaudeConfigDir(); + if (workingDir == null) { + sessionModePanel.reload(null, configDir, null); + return; + } + long requestId = ++sessionListRequestSeq; + Thread loader = new Thread(() -> { + java.util.List sessions; + try { + sessions = io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .listSessions(workingDir, configDir); + } catch (RuntimeException e) { + LOG.fine("Could not reload the session list: " + e); + sessions = java.util.List.of(); + } + java.util.List loaded = sessions; + javax.swing.SwingUtilities.invokeLater(() -> { + if (requestId != sessionListRequestSeq) return; // superseded + sessionModePanel.applySessionsIfCurrent(workingDir, configDir, loaded); + }); + }, "session-list-reload"); + loader.setDaemon(true); + loader.start(); + } + + /** Sequence of the last asynchronous refresh, to drop superseded results. */ + private long sessionListRequestSeq; + private File resolveDir() { Object item = pathCombo != null ? pathCombo.getSelectedItem() : null; if (item == null || item.toString().isBlank()) return null; @@ -453,6 +493,12 @@ private void onOpen() { saveToHistory(dir.getAbsolutePath()); } + if (!sessionModePanel.isSelectionValid()) { + showError(NbBundle.getMessage(ClaudeSessionSelectorPanel.class, + "ERR_NoSessionSelected")); + return; + } + errorLabel.setVisible(false); openListener.onOpen(dir, getSelectedProfileName(), extraArgsField.getText().trim(), sessionModePanel.getSelectedMode(), diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index f069d4a5..b3f02a61 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -12,6 +12,7 @@ import io.github.nbplugins.claudecodegui.model.SavedSession; import io.github.nbplugins.claudecodegui.model.SessionLifecycle; import io.github.nbplugins.claudecodegui.model.SessionMode; +import io.github.nbplugins.claudecodegui.process.ClaudeProcess; import io.github.nbplugins.claudecodegui.process.ClaudeSessionStore; import io.github.nbplugins.claudecodegui.settings.ClaudeCodePreferences; import io.github.nbplugins.claudecodegui.settings.ClaudeProfile; @@ -166,6 +167,26 @@ public class ClaudeSessionTab extends TopComponent /** Resume session ID actually used when the current session was started. */ private String activeResumeSessionId; + /** + * Bumped for every session start and on close, so a launch whose + * preparation finished late cannot start a process the user already + * replaced or closed. + */ + private volatile long startRequestSeq; + + /** + * Directory of a launch whose preparation is still running, or {@code null}. + * + *

{@link ClaudeSessionModel#getWorkingDirectory()} is only updated when + * the process actually starts, so while the launch is being prepared it + * still names the previous project. Restart actions offered in that window + * must use this directory instead. + */ + private volatile File pendingLaunchDir; + + /** Effective config dir of a launch still being prepared, or {@code null}. */ + private volatile Path pendingLaunchConfigDir; + // ------------------------------------------------------------------------- // MVC // ------------------------------------------------------------------------- @@ -331,23 +352,20 @@ public Dimension getMinimumSize() { selectorPanel = new ClaudeSessionSelectorPanel(dir, (d, pn, ea, mode, resumeId) -> onDirectoryOpened(d, pn, ea, mode, resumeId)); // --- chat UI --- - choiceMenuPanel = new ChoiceMenuPanel(() -> { - java.io.File wd = model.getWorkingDirectory(); - return wd != null ? wd.getAbsolutePath() : null; - }); + choiceMenuPanel = new ChoiceMenuPanel(this::currentOrPendingDirectoryPath); promptPanel = new ClaudePromptPanel( this::sendPrompt, this::cancelPrompt, controller::sendShiftTab, model::getPromptHistory, - () -> { - File wd = model.getWorkingDirectory(); - return wd != null ? wd.getAbsolutePath() : null; - }, + this::currentOrPendingDirectoryPath, () -> onSaveAndSwitch("", SessionMode.NEW, null), () -> openSwitchDialog(SessionMode.RESUME_SPECIFIC), this::showSessionStatistics, this::isSessionStatisticsAvailable); + // The launch is prepared off the EDT, so the input area is usable before + // the PTY connector exists; keep typed prompts until it does. + promptPanel.setSendAvailable(controller::canSendPrompt); // --- layout --- setLayout(new BorderLayout()); @@ -414,8 +432,7 @@ public static void openForDirectory(File dir) { public static void openForDirectory(File dir, String profileName) { SwingUtilities.invokeLater(() -> { for (TopComponent tc : WindowManager.getDefault().getRegistry().getOpened()) { - if (tc instanceof ClaudeSessionTab tab - && dir.equals(tab.getWorkingDirectory())) { + if (tc instanceof ClaudeSessionTab tab && tab.servesDirectory(dir)) { tab.requestActive(); return; } @@ -461,13 +478,16 @@ protected void componentOpened() { @Override protected void componentClosed() { super.componentClosed(); - File dir = model.getWorkingDirectory(); + // Keep the project being launched when closing during its preparation: + // the launch is cancelled below, but reopening must still restore it. + File dir = currentOrPendingDirectory(); pathToRestore = (dir != null) ? dir.getAbsolutePath() : null; if (pendingDiffOnClose != null) { try { pendingDiffOnClose.run(); } catch (Exception ex) { /* ignore */ } pendingDiffOnClose = null; } tabClosed = true; + startRequestSeq++; // cancel a launch whose preparation is still running displayNameGeneration.incrementAndGet(); // cancel pending polls if (trackedProjectInfo != null && projectNameListener != null) { trackedProjectInfo.removePropertyChangeListener(projectNameListener); @@ -538,7 +558,9 @@ public int getPersistenceType() { @Override public void writeExternal(ObjectOutput out) throws IOException { super.writeExternal(out); - File dir = model.getWorkingDirectory(); + // Persist the project being launched if one is still being prepared: + // the model still names the previous project in that window. + File dir = currentOrPendingDirectory(); out.writeUTF(dir != null ? dir.getAbsolutePath() : ""); out.writeUTF(selectorPanel.getSelectedProfileName()); out.writeUTF(selectorPanel.getExtraCliArgs()); @@ -824,15 +846,15 @@ private void startSession(File dir, String profileName, String extraCliArgs, SessionMode mode, String resumeSessionId) { selectorPanel.lock(); this.activeResumeSessionId = resumeSessionId; - Path configDir = null; - if (!ClaudeProfile.DEFAULT_NAME.equals(profileName)) { - ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); - if (profile != null && !profile.isDefault()) { - configDir = ClaudeProfileStore.resolveStorageDir( - profile, ClaudeCodePreferences.getProfilesDir()); - } - } + // Resolved for every profile, Default included: its extra environment + // may set CLAUDE_CONFIG_DIR too, and the process honors that. + Path configDir = resolveLaunchConfigDir(ClaudeProfileStore.findByName(profileName), + ClaudeCodePreferences.getProfilesDir()); final Path capturedConfigDir = configDir; + // Until the process starts, the model still describes the previous + // project; restart actions must follow this launch instead. + pendingLaunchDir = dir; + pendingLaunchConfigDir = capturedConfigDir; controller.setHangCallback( (cmd, err) -> showStartError(cmd, err, model.getWorkingDirectory(), capturedConfigDir)); updateDisplayName(dir); @@ -848,14 +870,43 @@ private void startSession(File dir, String profileName, String extraCliArgs, widget.getTerminalPanel().setBackground(termBg); } showChatLayout(widget); - try { - controller.startProcess(dir, profileName, extraCliArgs, mode, resumeSessionId, widget); - } catch (IOException ex) { - LOG.log(Level.SEVERE, "Claude process failed to start in " + dir, ex); - String command = controller.getLastAttemptedCommand(); - String errorMsg = ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName(); - showStartError(command, errorMsg, dir, capturedConfigDir); - } + startProcessAsync(dir, profileName, extraCliArgs, mode, resumeSessionId, + widget, capturedConfigDir); + } + + /** + * Prepares the launch-time session identity off the EDT and starts the + * process on the EDT afterwards. + * + *

Capturing the identity enumerates the project's session files; on + * projects with a large history that takes long enough to freeze the IDE if + * done inline. It must still describe the store before the process starts, + * so the launch waits for it. + */ + private void startProcessAsync(File dir, String profileName, String extraCliArgs, + SessionMode mode, String resumeSessionId, + ZoomableJediTermWidget widget, Path configDir) { + final long request = ++startRequestSeq; + Thread t = new Thread(() -> { + ClaudeSessionController.LaunchIdentity identity = + ClaudeSessionController.prepareLaunchIdentity( + dir, profileName, mode, resumeSessionId); + SwingUtilities.invokeLater(() -> { + if (request != startRequestSeq) return; + try { + controller.startProcess(dir, profileName, extraCliArgs, mode, + resumeSessionId, identity, widget); + } catch (IOException ex) { + LOG.log(Level.SEVERE, "Claude process failed to start in " + dir, ex); + String command = controller.getLastAttemptedCommand(); + String errorMsg = ex.getMessage() != null + ? ex.getMessage() : ex.getClass().getSimpleName(); + showStartError(command, errorMsg, dir, configDir); + } + }); + }, "session-launch-prep"); + t.setDaemon(true); + t.start(); } private void showChatLayout(JediTermWidget widget) { @@ -1229,39 +1280,32 @@ private void showSessionStatistics() { } public void openSwitchDialog(SessionMode initialMode) { - File workingDir = model.getWorkingDirectory(); + File workingDir = currentOrPendingDirectory(); if (workingDir == null) return; - String profileName = selectorPanel.getSelectedProfileName(); - Path claudeConfigDir = null; - if (profileName != null && !io.github.nbplugins.claudecodegui.settings.ClaudeProfile.DEFAULT_NAME.equals(profileName)) { - io.github.nbplugins.claudecodegui.settings.ClaudeProfile profile = - io.github.nbplugins.claudecodegui.settings.ClaudeProfileStore.findByName(profileName); - if (profile != null) { - claudeConfigDir = io.github.nbplugins.claudecodegui.settings.ClaudeProfileStore.resolveStorageDir( - profile, ClaudeCodePreferences.getProfilesDir()); - } - } - - io.github.nbplugins.claudecodegui.model.SavedSession recent = - io.github.nbplugins.claudecodegui.process.ClaudeSessionStore.findMostRecent( - workingDir.toPath(), claudeConfigDir); - long procStart = controller.getProcessStartedAt(); - // If a process is running and the most recent JSONL predates it, it's an OLD session — - // not the currently running one (which hasn't written its JSONL yet) - boolean isCurrentSession = recent != null - && (procStart == 0 || (recent.lastAt() != null - && recent.lastAt().toEpochMilli() >= procStart)); - String currentName = isCurrentSession ? recent.displayName() : ""; - String currentId = isCurrentSession ? recent.sessionId() : null; + // The controller captured the effective config dir and session ID at launch, + // so the running session's name and ID are read from that same store. + Path runningConfigDir = currentOrPendingConfigDir(); + String currentId = controller.resolveActiveSessionId(); + SavedSession current = currentId != null + ? ClaudeSessionStore.findSession(workingDir.toPath(), runningConfigDir, currentId) + : null; + String currentName = prefillSessionName(current); final ClaudeSessionTab self = this; - final Path configDir = claudeConfigDir; + final Path configDir = dialogSessionListConfigDir( + ClaudeProfileStore.findByName(selectorPanel.getSelectedProfileName()), + ClaudeCodePreferences.getProfilesDir(), runningConfigDir); java.awt.Frame mainFrame = org.openide.windows.WindowManager.getDefault().getMainWindow(); SaveAndSwitchDialog dialog = new SaveAndSwitchDialog( mainFrame, currentName, - currentId, + // The exclusion removes the running conversation from the list by + // ID alone. Only a list of the same store holds that exact + // conversation: another store (the profile's storage directory + // may have been changed in Options) can contain a copy under the + // same ID, and hiding it would leave the list without it. + excludedSessionId(currentId, configDir, runningConfigDir), workingDir.toPath(), configDir, initialMode, @@ -1272,8 +1316,144 @@ public void openSwitchDialog(SessionMode initialMode) { dialog.setVisible(true); } + /** + * Returns the session ID the switch dialog must hide from its list: the + * running conversation, but only when the listed store is the store the + * running process writes to. Session IDs are only unique within a store, + * so a different store's file with the same ID is an unrelated (possibly + * copied) conversation and has to stay selectable. + * + * @param currentId the running session's ID, or {@code null} + * @param listedStore store the dialog lists sessions from + * @param runningStore store the running session writes to + * @return the ID to exclude, or {@code null} + */ + static String excludedSessionId(String currentId, Path listedStore, Path runningStore) { + if (currentId == null) return null; + return sameStore(listedStore, runningStore) ? currentId : null; + } + + private static boolean sameStore(Path first, Path second) { + if (first == null || second == null) return first == null && second == null; + return first.toAbsolutePath().normalize() + .equals(second.toAbsolutePath().normalize()); + } + + /** + * Returns the name to pre-fill in the Save & Switch dialog for the current + * session: its custom title, else its slug, else an empty string. The raw + * session ID is never offered as a name. + * + * @param session current session, or {@code null} + * @return pre-fill value; never {@code null} + */ + static String prefillSessionName(SavedSession session) { + if (session == null) return ""; + String title = session.customTitle(); + if (title != null && !title.isBlank() && !title.equals(session.sessionId())) { + return title; + } + String slug = session.slug(); + return slug != null && !slug.isBlank() ? slug : ""; + } + + /** + * Package-private for tests: whether this tab already serves {@code dir}, + * including a launch that is still being prepared. During that window the + * model still names the previous project (or none), so looking only at + * {@link #getWorkingDirectory()} would let a second "Open with Claude Code" + * create another tab and start another process for the same project. + * + * @param dir working directory to look for + * @return {@code true} if this tab is (or is about to be) a session for it + */ + boolean servesDirectory(File dir) { + return dir != null && dir.equals(currentOrPendingDirectory()); + } + + /** + * Working directory handed to the prompt input for {@code @}-references to + * files pasted, dropped or completed while a launch is still being prepared. + * + *

The model still names the previous project until the process starts, + * so the pending launch's directory must win; otherwise a file from the new + * project would be stored as a path that only resolves inside the old one. + * + * @return absolute path of the directory the prompt will run in, or + * {@code null} when no directory is known yet + */ + String currentOrPendingDirectoryPath() { + File dir = currentOrPendingDirectory(); + return dir != null ? dir.getAbsolutePath() : null; + } + + /** + * Directory the Save & Switch dialog and restart actions operate on: the + * launch still being prepared (if any), otherwise the running session's. + * + *

{@code model.getWorkingDirectory()} is only updated when the process + * starts, so during a pending launch it still names the previous project. + */ + private File currentOrPendingDirectory() { + File pending = pendingLaunchDir; + return pending != null ? pending : model.getWorkingDirectory(); + } + + /** + * Effective Claude config dir matching {@link #currentOrPendingDirectory()}. + * + * @return the running session's config dir, else the pending launch's; + * {@code null} only when this tab has not prepared a launch yet + */ + private Path currentOrPendingConfigDir() { + Path active = controller.getActiveConfigDir(); + return active != null ? active : pendingLaunchConfigDir; + } + + /** + * Config directory a launch of {@code profile} will actually use. + * + *

Resolved exactly as {@link ClaudeProcess} resolves it for the process: + * the profile's storage dir, then any {@code CLAUDE_CONFIG_DIR} from the + * profile's extra environment variables. While the launch is pending the + * Save & Switch dialog must list that same store, or it would offer a + * conversation the replacement process cannot resume. + * + * @param profile profile of the pending launch; may be {@code null} + * @param profilesDir base directory for profile config dirs + * @return the effective config dir; the default store when nothing else is configured + */ + static Path resolveLaunchConfigDir(ClaudeProfile profile, Path profilesDir) { + // Not skipped for Default: its extra environment variables may set + // CLAUDE_CONFIG_DIR, and the launched process resolves it from there. + return ClaudeProcess.resolveConfigDir(profile, profilesDir); + } + + /** + * Config directory whose conversations the Save & Switch dialog lists. + * + *

The dialog belongs to the running session, but "Resume specific" starts + * a replacement process that reloads the selected profile from + * {@link ClaudeProfileStore}. If the profile's storage directory was changed + * in Options after this session started, the replacement writes to the new + * directory; listing the one captured at launch would offer conversations + * that process cannot resume. The running session's own name is still read + * from its captured directory. + * + * @param profile profile the replacement launch will use; may be {@code null} + * @param profilesDir base directory for profile config dirs + * @param runningConfigDir config dir of the running session, used when the + * launch directory cannot be resolved + * @return the config dir the replacement launch will use + */ + static Path dialogSessionListConfigDir(ClaudeProfile profile, Path profilesDir, + Path runningConfigDir) { + Path launchDir = resolveLaunchConfigDir(profile, profilesDir); + return launchDir != null ? launchDir : runningConfigDir; + } + public void onSaveAndSwitch(String sessionName, SessionMode mode, String resumeId) { - File dir = model.getWorkingDirectory(); + File dir = currentOrPendingDirectory(); String profileName = selectorPanel.getSelectedProfileName(); String extraCliArgs = selectorPanel.getExtraCliArgs(); @@ -1292,10 +1472,16 @@ public void onSaveAndSwitch(String sessionName, SessionMode mode, String resumeI // Restart with new mode if (dir != null) { - startSession(dir, profileName, extraCliArgs, mode, resumeId); + restartSession(dir, profileName, extraCliArgs, mode, resumeId); } } + /** Restarts the session with the chosen mode. Protected to allow overriding in tests. */ + protected void restartSession(File dir, String profileName, String extraCliArgs, + SessionMode mode, String resumeId) { + startSession(dir, profileName, extraCliArgs, mode, resumeId); + } + /** Closes this tab. Protected to allow overriding in tests. */ protected void doCloseTab() { close(); @@ -1308,6 +1494,11 @@ private void stopProcess() { /** Tears down the chat UI layout after the PTY has already been stopped. */ private void cleanupChatLayout() { + // The layout is gone: a launch whose preparation is still running would + // otherwise attach its process to the closed, detached widget. + startRequestSeq++; + pendingLaunchDir = null; + pendingLaunchConfigDir = null; if (terminalWidget != null) { terminalWidget.close(); terminalWidget = null; @@ -1332,6 +1523,8 @@ private void cleanupChatLayout() { } selectorPanel.setVisible(true); selectorPanel.unlock(); + // Reflect the name just saved by stopAndRename() in the session list. + selectorPanel.refreshSessionList(); add(selectorPanel, BorderLayout.CENTER); statusBar.setVisible(false); currentLifecycle = null; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java index 0a69772b..d97001e0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java @@ -287,6 +287,50 @@ public void reload(Path newWorkingDir, Path newClaudeConfigDir, String newExclud updateHistoryRadiosEnabled(); } + /** + * Shows an already loaded session list for the given directory / profile. + * Selecting the same session that was selected before keeps "Resume + * specific" usable across a refresh. Must be called on the EDT. + * + * @param newWorkingDir working directory the sessions belong to + * @param newClaudeConfigDir config dir the sessions belong to (null → ~/.claude) + * @param newExcludeSession session ID to exclude, or null + * @param sessions sessions loaded by the caller + */ + public void applySessions(Path newWorkingDir, Path newClaudeConfigDir, + String newExcludeSession, List sessions) { + this.workingDir = newWorkingDir; + this.claudeConfigDir = newClaudeConfigDir; + this.excludeSession = newExcludeSession; + setSessions(sessions); + updateHistoryRadiosEnabled(); + } + + /** + * Applies a background-loaded session list only while the panel is still + * showing the directory / profile the list was loaded for. + * + *

A reload started for the previous selection may finish after the user + * switches to another project or profile. Applying it then would replace the + * table and the stored store with stale data, so the Open button + * would pass a session ID of the old store for the new selection. Such a + * load is discarded. + * + * @param loadedWorkingDir directory the sessions were loaded for + * @param loadedConfigDir config dir the sessions were loaded for (null → ~/.claude) + * @param sessions loaded sessions + * @return {@code true} if the list was applied + */ + public boolean applySessionsIfCurrent(Path loadedWorkingDir, Path loadedConfigDir, + List sessions) { + if (!java.util.Objects.equals(this.workingDir, loadedWorkingDir) + || !java.util.Objects.equals(this.claudeConfigDir, loadedConfigDir)) { + return false; + } + applySessions(loadedWorkingDir, loadedConfigDir, this.excludeSession, sessions); + return true; + } + /** * Returns {@code true} if the current selection is valid: any mode except * {@link SessionMode#RESUME_SPECIFIC}, or RESUME_SPECIFIC with a row selected. @@ -358,11 +402,46 @@ private void refreshTable() { tableModel.setSessions(List.of()); return; } - List sessions = ClaudeSessionStore.listSessions(workingDir, claudeConfigDir); + setSessions(ClaudeSessionStore.listSessions(workingDir, claudeConfigDir)); + } + + /** + * Replaces the table contents, keeping the row that was selected before the + * update selected (the table model fires {@code fireTableDataChanged()}, + * which otherwise drops the selection and silently turns "Resume specific" + * into an invalid choice). + */ + private void setSessions(List loaded) { + String selectedId = selectedSessionId(); + List sessions = new ArrayList<>(loaded); if (excludeSession != null) { sessions.removeIf(s -> excludeSession.equals(s.sessionId())); } tableModel.setSessions(sessions); + if (selectedId != null) { + selectSessionById(selectedId); + } + } + + /** Session ID of the selected row, regardless of the selected mode. */ + private String selectedSessionId() { + int viewRow = sessionTable.getSelectedRow(); + if (viewRow < 0) return null; + int modelRow = sessionTable.convertRowIndexToModel(viewRow); + return tableModel.getSessionId(modelRow); + } + + /** Selects the row with the given session ID, if the table still contains it. */ + void selectSessionById(String sessionId) { + for (int row = 0; row < tableModel.getRowCount(); row++) { + if (sessionId.equals(tableModel.getSessionId(row))) { + int viewRow = sessionTable.convertRowIndexToView(row); + if (viewRow >= 0) { + sessionTable.setRowSelectionInterval(viewRow, viewRow); + } + return; + } + } } private void onRename() { @@ -372,7 +451,7 @@ private void onRename() { SavedSession session = tableModel.getSession(modelRow); if (session == null) return; - String current = session.displayName(); + String current = ClaudeSessionTab.prefillSessionName(session); String newName = JOptionPane.showInputDialog( this, "Rename session:", current); if (newName == null || newName.isBlank()) return; diff --git a/src/main/resources/io/github/nbplugins/claudecodegui/ui/Bundle.properties b/src/main/resources/io/github/nbplugins/claudecodegui/ui/Bundle.properties index 74a408af..388c484d 100644 --- a/src/main/resources/io/github/nbplugins/claudecodegui/ui/Bundle.properties +++ b/src/main/resources/io/github/nbplugins/claudecodegui/ui/Bundle.properties @@ -6,5 +6,6 @@ LBL_SelectProject=Select a project TAB_NewSession=New Session ERR_PathEmpty=Path must not be empty ERR_PathNotFound=Directory does not exist +ERR_NoSessionSelected=Select a session to resume, or choose another session mode MSG_ConfirmClose=Close all Claude Code sessions? TTL_ConfirmClose=Close Claude Code diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7cd2b478..02778cba 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3,15 +3,19 @@ import io.github.nbplugins.claudecodegui.model.ClaudeSessionModel; import io.github.nbplugins.claudecodegui.model.EditMode; import io.github.nbplugins.claudecodegui.model.SessionLifecycle; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.github.nbplugins.claudecodegui.model.ChoiceMenuModel; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -34,6 +38,12 @@ void setUp() { controller = new ClaudeSessionController(model, Collections::emptyList); } + @AfterEach + void tearDown() { + // Drops the controller from the live-session / claim registries. + controller.stopProcess(); + } + // ------------------------------------------------------------------------- // onClaudeIdle // ------------------------------------------------------------------------- @@ -761,6 +771,341 @@ void switchModel_customIndex_noopWithoutConnector() throws Exception { assertDoesNotThrow(() -> c.switchModel(2)); } + /** + * The worker activates a text-input option with a digit and only then + * records the new focus. Terminal input classified between the write and + * that record still sees the previous option, so a digit meant for the + * editor selects another item, retires the menu and leaks the line as + * prompt evidence. The write and the record must share the recorder's lock. + */ + @Test + void theActivatingWriteAndItsFocusUpdateAreAtomic() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", + List.of(new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3", null, + false, false, true)), + 0)); + + CountDownLatch inWrite = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("3".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + inWrite.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("TYPE:3:auto answer"); + assertTrue(inWrite.await(5, TimeUnit.SECONDS), + "the worker wrote the activating digit"); + + java.util.concurrent.atomic.AtomicReference submitted = + new java.util.concurrent.atomic.AtomicReference<>(); + CountDownLatch inputReturned = new CountDownLatch(1); + Thread writer = new Thread(() -> { + submitted.set(controller.simulateTerminalInput( + "1feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L)); + inputReturned.countDown(); + }, "terminal-writer"); + writer.start(); + + // On the fixed code the writer waits for the recorder's lock; without + // the shared lock it runs inside the gap and returns here. + inputReturned.await(500, TimeUnit.MILLISECONDS); + release.countDown(); + writer.join(5_000L); + + assertNull(submitted.get(), + "the digit answers the option's editor, not another item"); + assertEquals(0, inputReturned.getCount(), + "the terminal writer must not be left blocked"); + } + + /** + * A panel answer naming an ordinary option while the menu's text editor + * has the focus is written as arrows, because a digit would be text for + * that editor. Once an arrow leaves the editor, a digit typed in the + * terminal selects an item again: without recording the forwarded focus + * the recorder keeps treating the digit as editor text and swallows the + * prompt typed after it. + */ + @Test + void anArrowAnswerLeavesTheTextEditorForTheRecorder() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 2)); + + CountDownLatch firstArrow = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicBoolean blocked = + new java.util.concurrent.atomic.AtomicBoolean(); + controller.simulatePtyWrite(bytes -> { + if (Arrays.equals(bytes, new byte[]{0x1b, '[', 'A'}) + && blocked.compareAndSet(false, true)) { + firstArrow.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("1"); + assertTrue(firstArrow.await(5, TimeUnit.SECONDS), + "the worker wrote the first arrow out of the editor"); + + AtomicReference submitted = new AtomicReference<>(); + CountDownLatch inputReturned = new CountDownLatch(1); + Thread writer = new Thread(() -> { + submitted.set(controller.simulateTerminalInput( + "1hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L)); + inputReturned.countDown(); + }, "terminal-writer"); + writer.start(); + + // On the fixed code the writer waits for the recorder's lock, held by + // the worker until it records the focus; without the record it runs + // inside the gap and returns here. + inputReturned.await(500, TimeUnit.MILLISECONDS); + release.countDown(); + writer.join(5_000L); + + assertEquals("hello", submitted.get(), + "the digit selects an option once the arrows left the editor"); + assertEquals(0, inputReturned.getCount(), + "the terminal writer must not be left blocked"); + } + + /** + * The key that answers a menu and the retirement of that menu must be + * atomic with respect to terminal input. A prompt typed right after the + * submitting key would otherwise still be classified against the menu the + * TUI already dismissed and swallowed as another menu answer. + */ + @Test + void theSubmittingKeyRetiresTheMenuBeforeTheNextPrompt() throws Exception { + List options = new ArrayList<>(); + for (int i = 1; i <= 12; i++) { + options.add(new ChoiceMenuModel.Option("Item " + i, String.valueOf(i))); + } + // The cursor already sits on item 10: the answer submits it with Enter. + model.setActiveChoiceMenu( + new ChoiceMenuModel("Pick an item", options, 0, false, 10)); + + CountDownLatch submitInWrite = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("\r".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + submitInWrite.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("10"); + assertTrue(submitInWrite.await(5, TimeUnit.SECONDS), + "the worker wrote the submitting Enter"); + + AtomicReference submitted = new AtomicReference<>(); + CountDownLatch inputReturned = new CountDownLatch(1); + Thread writer = new Thread(() -> { + submitted.set(controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L)); + inputReturned.countDown(); + }, "terminal-writer"); + writer.start(); + + // On the fixed code the writer waits for the recorder's lock, held by + // the worker until the menu is retired; without it, the writer runs + // inside the gap and returns here. + inputReturned.await(500, TimeUnit.MILLISECONDS); + release.countDown(); + writer.join(5_000L); + + assertEquals("hello", submitted.get(), + "the prompt after the submitting key is not a menu answer"); + assertEquals(0, inputReturned.getCount(), + "the terminal writer must not be left blocked"); + } + + /** + * Text typed into an open menu — a search filter or a text option's + * answer — belongs to the menu, not to the next prompt. When an answer + * worker submits the menu, the recorded line must be dropped with it: + * otherwise the next prompt sent from the panel is composed with the + * menu's text and published as evidence the conversation never received. + */ + @Test + void aMenuAnswerIsNotDraftedIntoTheNextPanelPrompt() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + CountDownLatch digitDone = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("3".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + digitDone.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("TYPE:3:auto"); + assertTrue(digitDone.await(5, TimeUnit.SECONDS), + "the worker activated the text option"); + + // Keys typed during the worker's pause answer the menu's editor. + controller.simulateTerminalInput( + "feedback".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L); + + release.countDown(); + long deadline = System.currentTimeMillis() + 5_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the worker retired the menu once its answer was forwarded"); + + assertEquals("hello", controller.simulateGuiSend("hello", START_MS + 300L), + "the menu's answer must not become the draft of the next prompt"); + } + + /** + * A NEW tab first guessed a sibling's {@code b}, then its own newer {@code a} + * appeared. A third tab resuming {@code a} makes the file a live tab's own, + * but that resume does not prove the earlier guess was correct: {@code a} + * still carries this run's submission, so it must keep competing and the + * stop must not write the name into the guessed file. + */ + @Test + void resumingTheCompetingFileDoesNotMakeTheGuessSafeToRename() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + // The sibling's file is written first and this tab guesses it. + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "hello", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_600L); + assertEquals("b", controller.resolveActiveSessionId(), + "the only file written since the submission is provisionally adopted"); + controller.simulateAwaitIdentityScan(); + + // This tab's own newer file appears and holds the same text. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + + // A third tab resumes a, so the competitor now belongs to a live tab. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 20_000L, true); + tabC.simulateLiveSession(config, "a", Set.of("a", "b")); + try { + controller.stopAndRename("A-title"); + } finally { + tabC.stopProcess(); + } + } finally { + tabB.stopProcess(); + } + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the guessed conversation must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing file keeps the guess unresolved"); + } + + /** + * A model switch writes the command at the TUI's cursor, so a draft typed + * in the terminal is submitted together with it. The write bypasses the + * terminal recorder, so unless the command retires the draft the next panel + * send is published as the draft plus its own text — evidence for a prompt + * the conversation never received. + */ + @Test + void aModelCommandSubmitsTheTerminalDraftAndForgetsIt() throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + controller.simulateConnector( + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p)); + + java.lang.reflect.Field customField = + ClaudeSessionController.class.getDeclaredField("customModelIds"); + customField.setAccessible(true); + customField.set(controller, java.util.List.of("openai/gpt-4o")); + java.lang.reflect.Field stdCount = + ClaudeSessionController.class.getDeclaredField("standardModelCount"); + stdCount.setAccessible(true); + stdCount.setInt(controller, 2); + + CountDownLatch written = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("/model openai/gpt-4o\r".equals( + new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + written.countDown(); + } + }); + + model.setLifecycle(SessionLifecycle.READY); + controller.simulateTerminalInput( + "draft".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + + controller.switchModel(2); + assertTrue(written.await(5, TimeUnit.SECONDS), + "the model command was written"); + + assertEquals("next", controller.simulateGuiSend("next", START_MS + 200L), + "the draft was submitted by the model command, not by the next send"); + } finally { + p.destroyForcibly(); + } + } + /** * Verifies that {@code stopProcess} resets {@code customModelIds} and * {@code standardModelCount} to their initial values. @@ -1376,6 +1721,8061 @@ void isModelCommandPendingWithBlankLine_falseWhenModelInHistory() { assertFalse(controller.isModelCommandPendingWithBlankLine(lines)); } + // ------------------------------------------------------------------------- + // stopAndRename — must target the session that was running + // ------------------------------------------------------------------------- + + @org.junit.jupiter.api.io.TempDir + java.nio.file.Path tmpDir; + + /** Fixed launch timestamp; tests set file mtimes relative to it. */ + private static final long START_MS = 1_700_000_000_000L; + + private static String hashOf(java.nio.file.Path workDir) { + return workDir.toAbsolutePath().toString().replace('/', '-'); + } + + private static java.nio.file.Path sessionDir(java.nio.file.Path configDir, + java.nio.file.Path workDir) throws java.io.IOException { + java.nio.file.Path dir = configDir.resolve("projects").resolve(hashOf(workDir)); + java.nio.file.Files.createDirectories(dir); + return dir; + } + + private static void writeSession(java.nio.file.Path dir, String id, String slug) + throws java.io.IOException { + java.nio.file.Files.writeString(dir.resolve(id + ".jsonl"), + "{\"sessionId\":\"" + id + "\",\"slug\":\"" + slug + + "\",\"timestamp\":\"2024-01-01T10:00:00Z\",\"type\":\"summary\"}\n"); + } + + /** Appends a user message, as the running process writes it into the JSONL. */ + private static void appendUserPrompt(java.nio.file.Path dir, String id, String text, + long millis) throws java.io.IOException { + java.nio.file.Files.writeString(dir.resolve(id + ".jsonl"), + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"" + text + + "\"},\"timestamp\":\"" + java.time.Instant.ofEpochMilli(millis) + + "\"}\n", + java.nio.file.StandardOpenOption.APPEND); + } + + /** Appends an assistant record, as the running process writes a reply. */ + private static void appendAssistantLine(java.nio.file.Path dir, String id, long millis) + throws java.io.IOException { + java.nio.file.Files.writeString(dir.resolve(id + ".jsonl"), + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"ok\"}" + + ",\"timestamp\":\"" + java.time.Instant.ofEpochMilli(millis) + "\"}\n", + java.nio.file.StandardOpenOption.APPEND); + } + + private static void setMtime(java.nio.file.Path file, long millis) throws java.io.IOException { + java.nio.file.Files.setLastModifiedTime(file, + java.nio.file.attribute.FileTime.fromMillis(millis)); + } + + private static long countCustomTitles(java.nio.file.Path file, String value) + throws java.io.IOException { + String json = "\"customTitle\":\"" + value + "\""; + return java.nio.file.Files.readAllLines(file).stream() + .filter(line -> line.contains("\"type\":\"custom-title\"")) + .filter(line -> line.contains(json)) + .count(); + } + + @Test + void stopAndRename_writesIntoCapturedConfigDir() throws Exception { + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path profileDir = tmpDir.resolve("profiles").resolve("Work"); + java.nio.file.Path dir = sessionDir(profileDir, work); + writeSession(dir, "s1", "slug"); + setMtime(dir.resolve("s1.jsonl"), START_MS - 1_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(profileDir, "s1", Set.of("s1")); + + controller.stopAndRename("My Session"); + + io.github.nbplugins.claudecodegui.model.SavedSession s = + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, profileDir, "s1"); + assertNotNull(s); + assertEquals("My Session", s.customTitle(), + "the title must be written into the profile's own config dir"); + } + + @Test + void stopAndRename_targetsLaunchSession_notAnotherTab() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // Another tab is running "other-tab" and wrote it after our launch. + writeSession(dir, "other-tab", "other"); + setMtime(dir.resolve("other-tab.jsonl"), START_MS + 500L); + ClaudeSessionController otherTab = + new ClaudeSessionController(new ClaudeSessionModel(), Collections::emptyList); + otherTab.simulateProcessStart(START_MS - 500L, true); + otherTab.simulateLiveSession(config, "other-tab", Set.of("other-tab")); + + // Snapshot taken right before our process launched. + Set before = Set.of("other-tab"); + writeSession(dir, "this-session", "this"); + setMtime(dir.resolve("this-session.jsonl"), START_MS + 1_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, null, before); + + try { + controller.stopAndRename("Closed Session"); + + assertEquals("Closed Session", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "this-session").customTitle(), + "the session created by this run must be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "other-tab").customTitle(), + "another tab's session must not be touched"); + } finally { + otherTab.stopProcess(); + } + } + + @Test + void stopAndRename_followsSessionSwitchedInProcess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "first", "first"); + setMtime(dir.resolve("first.jsonl"), START_MS - 5_000L); + writeSession(dir, "second", "second"); + // The TUI's /resume picker switched conversations after the launch: + // the second file is the one the process writes. + setMtime(dir.resolve("second.jsonl"), START_MS + 2_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "first", Set.of("first", "second")); + + controller.stopAndRename("Switched Name"); + + assertEquals("Switched Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "second").customTitle(), + "the name must go to the conversation the process switched to"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "first").customTitle(), + "the conversation left by the switch must not be renamed"); + } + + @Test + void newSessionsClaimTheirOwnFileBeforeSiblingGuesses() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // Tab A (NEW) wrote its session after its first prompt, but has not + // opened Save & Switch yet, so its identity is still unresolved. + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS + 1_000L); + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + // Tab B (NEW) launches later and writes its own, newer session. + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 5_000L); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 4_000L, true); + tabB.simulateLiveSession(config, null, Set.of("a")); + + try { + // Resolving B must not make A claim B's file on its behalf. + assertEquals("b", tabB.resolveActiveSessionId(), + "tab B must resolve to its own session"); + assertEquals("a", controller.resolveActiveSessionId(), + "tab A must keep the session it created"); + assertEquals("b", tabB.resolveActiveSessionId(), + "tab B must still resolve to its own session"); + } finally { + tabB.stopProcess(); + } + } + + @Test + void inProcessSwitch_releasesTheClaimOnThePreviousSession() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // Tab A starts on "a"; tab B starts on "b" after it. + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 1_000L); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 2_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // Tab A switches to "c" inside the TUI; the screen poll notices it + // after ~5 s and must release the claim on the abandoned "a". + writeSession(dir, "c", "c"); + setMtime(dir.resolve("c.jsonl"), START_MS + 3_000L); + for (int i = 0; i < 10; i++) { + controller.simulateIdentityTick(); + } + + // Tab B switches to the conversation A left behind and prompts, so + // "a" is the newest file it has written. + setMtime(dir.resolve("a.jsonl"), START_MS + 6_000L); + assertEquals("a", tabB.resolveActiveSessionId(), + "the claim released by A's switch must let B resume that session"); + } finally { + tabB.stopProcess(); + } + } + + @Test + void processExit_releasesTheSessionClaims() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // Tab A runs "a" and its PTY exits from inside the terminal. + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + controller.simulateProcessExit(); + + // Tab B starts later and switches to the now-free "a". + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 4_000L); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 3_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + setMtime(dir.resolve("a.jsonl"), START_MS + 6_000L); + assertEquals("a", tabB.resolveActiveSessionId(), + "an exited process must not keep claiming its session"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A tab may start a saved session at the same moment the tab that already + * owns it exits. The starting tab's claim must survive the exiting tab's + * cleanup; if it were lost, a sibling scan would see the file as unowned + * and could adopt (and later rename) the wrong conversation. + */ + @Test + void claimMadeWhileAnotherTabReleases_isNotLost() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + sessionDir(config, work); + + ClaudeSessionModel observerModel = new ClaudeSessionModel(); + observerModel.setWorkingDirectory(work.toFile()); + ClaudeSessionController observer = + new ClaudeSessionController(observerModel, Collections::emptyList); + observer.simulateProcessStart(START_MS, true); + observer.simulateLiveSession(config, "observer", Set.of()); + + try { + for (int i = 0; i < 2_000; i++) { + String id = "race-" + i; + + ClaudeSessionModel closingModel = new ClaudeSessionModel(); + closingModel.setWorkingDirectory(work.toFile()); + ClaudeSessionController closing = + new ClaudeSessionController(closingModel, Collections::emptyList); + closing.simulateLiveSession(config, id, Set.of()); + + ClaudeSessionModel startingModel = new ClaudeSessionModel(); + startingModel.setWorkingDirectory(work.toFile()); + ClaudeSessionController starting = + new ClaudeSessionController(startingModel, Collections::emptyList); + + java.util.concurrent.CountDownLatch go = + new java.util.concurrent.CountDownLatch(1); + Thread release = new Thread(() -> { + await(go); + closing.simulateProcessExit(); + }); + Thread claim = new Thread(() -> { + await(go); + starting.simulateLiveSession(config, id, Set.of()); + }); + release.start(); + claim.start(); + go.countDown(); + release.join(); + claim.join(); + + assertTrue( + observer.simulateClaimedByOthers(work.toFile(), config).contains(id), + "the claim of the starting tab was lost on iteration " + i); + + starting.stopProcess(); + } + } finally { + observer.stopProcess(); + } + } + + private static void await(java.util.concurrent.CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Test + void newSessionResumingSavedConversation_resolvesAfterPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // The saved conversation exists before the NEW tab is launched. + writeSession(dir, "saved", "saved"); + setMtime(dir.resolve("saved.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, null, Set.of("saved")); + + // The TUI's /resume picker switches to the saved conversation, and a + // prompt from the plugin rewrites it. + controller.simulatePromptSubmittedAt(START_MS + 2_000L); + setMtime(dir.resolve("saved.jsonl"), START_MS + 3_000L); + + controller.stopAndRename("Resumed Name"); + + assertEquals("Resumed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "saved").customTitle(), + "a saved conversation resumed before the first JSONL of the " + + "new session must still be renamable"); + } + + /** + * Two tabs can resume the same saved conversation, in which case both own + * its session file. The shared file must stay eligible for each tab's own + * identity scan; otherwise a newer, unrelated file written by a third tab + * is resolved instead and its conversation gets renamed on stop. + */ + @Test + void sharedSessionStaysEligibleForIdentityScans() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // The shared conversation has the newest mtime; the unrelated file was + // written after both launches but is older. + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("shared.jsonl"), START_MS + 5_000L); + writeSession(dir, "other", "other"); + setMtime(dir.resolve("other.jsonl"), START_MS + 3_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "shared", Set.of("shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 500L, true); + tabB.simulateLiveSession(config, "shared", Set.of("shared")); + + // A third tab wrote another file and exited. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "other", Set.of("other")); + tabC.simulateProcessExit(); + + try { + assertEquals("shared", controller.resolveActiveSessionId(), + "a session shared with another live tab must stay eligible"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A tab can join another live tab's conversation from the in-process resume + * picker. The other tab still owns that file, so it must not stay an + * unconditional exclusion: the tab follows the switch and shares ownership. + */ + @Test + void inProcessResumeIntoAnotherTabsSession_followsTheSwitch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // Tab A picks B's conversation in its TUI and sends a prompt; the + // write lands in B's file, which B still owns. + controller.simulatePromptText("continue in the resumed tab"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "b", "continue in the resumed tab", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "a tab that resumed another tab's conversation must follow it"); + + controller.stopAndRename("Switched Name"); + assertEquals("Switched Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the entered name must go to the conversation on screen"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation left by the switch must not be renamed"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A NEW tab can join another live tab's conversation from the in-process + * resume picker before it has created a JSONL of its own, so its identity is + * still unknown when the prompt is sent. + */ + @Test + void newTabResumingAnotherTabsConversationBeforeItsOwnFile_resolves() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + // Tab B owns the conversation. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("shared")); + + // Tab A is NEW and has not written anything yet. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("shared")); + + try { + // A joins B's conversation in the TUI and sends a prompt; the write + // lands in the shared file. + controller.simulatePromptText("joined from a new tab"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "shared", "joined from a new tab", START_MS + 10_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 11_000L); + + assertEquals("shared", controller.resolveActiveSessionId(), + "a NEW tab that resumed a live tab's conversation must resolve it"); + + controller.stopAndRename("Shared Name"); + assertEquals("Shared Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "shared").customTitle(), + "the entered name must be saved to the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The counterpart of the test above: a NEW tab's first prompt is scanned + * before the tab has written its own file. A sibling tab finishing a turn at + * that moment owns a file written after the prompt, but it contains only + * that tab's messages — not this tab's prompt — so it must not be adopted. + */ + @Test + void newTabPromptingWhileAnotherTabWrites_doesNotAdoptItsSession() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("b")); + + try { + // A's scan runs before A's own file exists; meanwhile B finishes a + // turn in the same store. + controller.simulatePromptText("a brand new prompt"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "b", "b's own turn", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertNull(controller.resolveActiveSessionId(), + "another tab's turn is not evidence of a shared resume"); + + controller.stopAndRename("A Name"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "a pending first prompt must not rename another tab's conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A third tab can write its own conversation after this tab's prompt and + * then exit, leaving an unclaimed and even newer file. The conversation that + * received this tab's prompt — still owned by its other tab — must win over + * that mtime-only candidate. + */ + @Test + void promptEvidenceWinsOverANewerUnclaimedFile() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b", "c")); + + // A third tab writes its own conversation after the prompt and exits, + // so its file is the newest and no longer claimed by a live tab. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 2_000L, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "b", "c")); + setMtime(dir.resolve("c.jsonl"), START_MS + 12_000L); + tabC.stopProcess(); + + try { + // Tab A joined B's conversation and sent its prompt there. + controller.simulatePromptText("joined b"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "b", "joined b", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the file holding this tab's prompt must beat a newer unclaimed one"); + + controller.stopAndRename("Resumed Name"); + assertEquals("Resumed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the entered name must go to the conversation on screen"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "c").customTitle(), + "the exited tab's conversation must not be renamed"); + } finally { + tabB.stopProcess(); + } + } + + /** + * After a plugin prompt, the user can resume another conversation from the + * TUI and type its next prompt directly in the terminal, bypassing + * sendPrompt(). The earlier prompt no longer describes the conversation on + * screen, so the file times must decide again. + */ + @Test + void terminalInputAfterThePromptInvalidatesTheEvidence() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulatePromptText("plugin prompt"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "a", "plugin prompt", START_MS + 10_500L); + controller.simulateLiveSession(config, "a", Set.of("a")); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_500L); + + // The user types in the terminal: the process moves to "b". + controller.simulateExternalInput(START_MS + 11_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "input typed after the prompt makes the old prompt stale evidence"); + + controller.stopAndRename("On Screen"); + assertEquals("On Screen", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the entered name must follow the conversation on screen"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation left behind must not be renamed"); + } + + /** + * Two tabs in the same store can submit the same prompt text ({@code continue}). + * Then both files hold "this tab's prompt", so the session the tab already + * tracks must win over a sibling's file that happens to be scanned first. + */ + @Test + void promptEvidencePrefersTheTabsOwnSessionWhenPromptsMatch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "sibling", "sibling"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("sibling.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "sibling")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "sibling", Set.of("own", "sibling")); + + try { + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + // Both conversations received an identical prompt after that moment; + // the sibling's file is even newer. + appendUserPrompt(dir, "own", "continue", START_MS + 10_500L); + appendUserPrompt(dir, "sibling", "continue", START_MS + 10_600L); + setMtime(dir.resolve("own.jsonl"), START_MS + 11_000L); + setMtime(dir.resolve("sibling.jsonl"), START_MS + 11_500L); + + assertEquals("own", controller.resolveActiveSessionId(), + "the session this tab already tracks must win over an identical prompt"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A resume driven entirely from the keyboard: the user picks another tab's + * conversation through the TUI and types the next prompt in the terminal. + * That prompt is evidence for the conversation now on screen, even though it + * was not sent by the plugin. + */ + @Test + void promptTypedInTheTerminalFollowsASharedResume() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + appendUserPrompt(dir, "shared", "typed in terminal", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + controller.simulateTerminalInput( + "typed in terminal\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + + assertEquals("shared", controller.resolveActiveSessionId(), + "a prompt typed in the terminal proves the conversation on screen"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The same resume driven by a prompt recalled from the TUI history: Up + * fills the TUI input with text the plugin never sees, and Enter submits + * it. The submission is real, so the file this process writes after it — + * the conversation on screen — must be followed even though its text + * cannot be matched. + */ + @Test + void promptRecalledFromTheTerminalHistoryFollowsTheConversationOnScreen() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + // The recalled prompt is appended to the resumed conversation. + appendUserPrompt(dir, "shared", "recalled prompt", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // Up recalls it into the TUI input; nothing is typed here. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L); + + assertEquals("shared", controller.resolveActiveSessionId(), + "a recalled prompt proves the conversation on screen"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A NEW session's first record can be written between the pre-stop + * identity lookup and the moment the process exits. The rename must still + * target that file instead of silently dropping the entered name. + */ + @Test + void aSessionFileWrittenWhileStoppingIsStillRenamed() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + java.util.concurrent.atomic.AtomicBoolean written = + new java.util.concurrent.atomic.AtomicBoolean(); + ClaudeSessionController underTest = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void stopWriter() { + if (written.compareAndSet(false, true)) { + try { + writeSession(dir, "late", "late"); + appendUserPrompt(dir, "late", "first prompt", + START_MS + 5_000L); + setMtime(dir.resolve("late.jsonl"), START_MS + 5_000L); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + super.stopWriter(); + } + }; + underTest.simulateProcessStart(START_MS, true); + underTest.simulateLiveSession(config, null, Set.of()); + + underTest.stopAndRename("Late session"); + + io.github.nbplugins.claudecodegui.model.SavedSession saved = + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore.findSession( + work, config, "late"); + assertNotNull(saved, "the session written while stopping must be renamed"); + assertEquals("Late session", saved.customTitle()); + } + + /** + * A tab tracks conversation A, the user switches to B in the TUI, and B's + * first record is only written while the process shuts down. The title must + * follow B — the conversation that was on screen — not the previously + * tracked A. + */ + @Test + void aConversationSwitchedWhileStoppingIsRenamedInsteadOfThePreviousOne() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + java.util.concurrent.atomic.AtomicBoolean written = + new java.util.concurrent.atomic.AtomicBoolean(); + ClaudeSessionController underTest = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void stopWriter() { + if (written.compareAndSet(false, true)) { + try { + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "switched prompt", + START_MS + 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 5_000L); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + super.stopWriter(); + } + }; + underTest.simulateProcessStart(START_MS, true); + underTest.simulateLiveSession(config, "a", Set.of("a")); + + underTest.stopAndRename("Switched session"); + + io.github.nbplugins.claudecodegui.model.SavedSession saved = + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore.findSession( + work, config, "b"); + assertNotNull(saved, "the conversation written while stopping is the one on screen"); + assertEquals("Switched session", saved.customTitle()); + assertEquals(0, countCustomTitles(dir.resolve("a.jsonl"), "Switched session"), + "the previously tracked conversation must not be renamed"); + } + + /** + * Up recalls a prompt from the TUI history and the user edits it before + * submitting. Only the edited characters reach this parser, so the full + * prompt is unknown; treating the fragment as the whole prompt would make + * the exact-text evidence fail and Save & Switch rename the wrong session. + */ + @Test + void anEditedHistoryRecallKeepsThePromptTextUnknown() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + appendUserPrompt(dir, "shared", "recalled prompt suffix", + START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // Up recalls the prompt, then " suffix" is appended and submitted. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + controller.simulateTerminalInput( + " suffix".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_001L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_002L), + "the recorded characters are only part of the recalled prompt"); + + assertEquals("shared", controller.resolveActiveSessionId(), + "an edited recall proves the conversation on screen by time"); + } finally { + tabB.stopProcess(); + } + } + + /** + * An unknown-text prompt switched into another live tab's conversation. + * A third tab then writes its own conversation and exits: its file is + * unclaimed and newer than the resumed one, but the resumed file holds the + * submitted user record. The evidence must win over the bare file time, or + * Save & Switch renames the unrelated conversation. + */ + @Test + void anUnknownPromptAdoptsTheResumedConversationOverANewerExitedOne() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + writeSession(dir, "third", "third"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("third.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + // The resumed conversation receives the submitted prompt. + appendUserPrompt(dir, "shared", "recalled prompt", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + // An unrelated, already-exited session is written even later. + setMtime(dir.resolve("third.jsonl"), START_MS + 13_000L); + + // Up recalls a prompt and Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + assertEquals("shared", controller.resolveActiveSessionId(), + "the shared user record must beat a newer unclaimed file"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A tab whose conversation was known at launch switches to another live + * tab's conversation and submits a recalled prompt. An exited tab wrote its + * own human prompt afterwards, so its file is unclaimed and holds a user + * record; the shared conversation holds this tab's submission. The new-file + * priority belongs to unresolved NEW tabs only, so the unrelated record + * must not pin this tab and make Save & Switch rename it. + */ + @Test + void anUnknownPromptFromALaunchedTabKeepsTheSharedDestination() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + writeSession(dir, "exited", "exited"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("exited.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared", "exited")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared", "exited")); + + try { + // Up recalls a prompt and Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The shared conversation receives the submission. + appendUserPrompt(dir, "shared", "recalled prompt", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + // An exited tab wrote its own human prompt after the submission. + appendUserPrompt(dir, "exited", "unrelated work", START_MS + 11_600L); + setMtime(dir.resolve("exited.jsonl"), START_MS + 13_000L); + + assertEquals("shared", controller.resolveActiveSessionId(), + "the shared submission must win over an unrelated human record"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The destination of a recalled prompt is another live tab's conversation, + * but many newer unclaimed files (assistant output only) were written since + * the submission. The shared conversations must be filtered before the + * candidate list is truncated, or the destination is never inspected and + * the file-time fallback renames an unrelated conversation. + */ + @Test + void aSharedDestinationIsFoundDespiteManyNewerUnclaimedFiles() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The shared conversation receives the submission. + appendUserPrompt(dir, "shared", "recalled prompt", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // A crowd of newer, unclaimed conversations with assistant output + // only fills a globally limited candidate list. + for (int i = 1; i <= 8; i++) { + writeSession(dir, "noise" + i, "noise" + i); + setMtime(dir.resolve("noise" + i + ".jsonl"), START_MS + 12_000L + i * 100L); + } + + assertEquals("shared", controller.resolveActiveSessionId(), + "the shared conversations must be filtered before the limit"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A launch-known tab submits a recalled prompt at about the same time as a + * live sibling submits its own human prompt: the shared pass adopts the + * sibling's conversation for a moment. The launch conversation then + * receives this tab's user record and becomes the newest file, so the tab + * must take its identity back instead of staying pinned to the sibling — + * otherwise Save & Switch renames the sibling's conversation. + */ + @Test + void anUnknownPromptRecoversTheLaunchConversationAfterASharedGuess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // Up recalls a prompt and Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's own human prompt lands first. + appendUserPrompt(dir, "b", "sibling prompt", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "with no record of its own yet, the tab follows the sibling"); + + // This tab's submission lands in its launch conversation, later. + appendUserPrompt(dir, "a", "recalled prompt", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation's record must take the identity back"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the recovered conversation"); + assertEquals("b", tabB.resolveActiveSessionId(), + "the sibling keeps its own conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Two unclaimed files hold the same prompt: an exited tab's conversation and + * this NEW tab's own file, written later. The store returns the newest + * first; the scan must keep that order and take this tab's own file, or the + * older conversation is renamed and kept as the identity on later scans. + */ + @Test + void anUnclaimedNewerOwnFileWinsOverAnOlderOneWithTheSamePrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "z", "z"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("z.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, false); + controller.simulateLiveSession(config, null, Set.of("a", "z")); + + controller.simulateTerminalInput( + "same".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 11_000L); + assertEquals("same", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The exited tab's conversation already holds the same prompt. + appendUserPrompt(dir, "a", "same", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's own file was written later. + appendUserPrompt(dir, "z", "same", START_MS + 11_600L); + setMtime(dir.resolve("z.jsonl"), START_MS + 12_600L); + + assertEquals("z", controller.resolveActiveSessionId(), + "the newest unclaimed match is this tab's own file"); + assertEquals("z", controller.resolveActiveSessionId(), + "later scans must keep the same file"); + } + + /** + * The launch snapshot is prepared on a worker thread and the spawn re-reads + * the profile. If the profile's storage moved in between, the snapshot + * lists the old store, whose files make every file in the new store look + * new — one written within the file-time slack could then be adopted as + * this tab's own. The snapshot must be recaptured in the spawn store. + */ + @Test + void aProfileMovedDuringPreparationRecapturesTheLaunchSnapshot() throws Exception { + java.nio.file.Path oldConfig = tmpDir.resolve("old-config"); + java.nio.file.Path newConfig = tmpDir.resolve("new-config"); + java.nio.file.Path work = tmpDir.resolve("project"); + + writeSession(sessionDir(oldConfig, work), "old", "old"); + writeSession(sessionDir(newConfig, work), "current", "current"); + + ClaudeSessionController.LaunchIdentity prepared = + ClaudeSessionController.prepareLaunchIdentity( + work.toFile(), oldConfig, + io.github.nbplugins.claudecodegui.model.SessionMode.NEW, null); + assertEquals(Set.of("old"), prepared.preStartSessionIds()); + + ClaudeSessionController.LaunchIdentity same = + ClaudeSessionController.reconcileLaunchIdentity( + work.toFile(), + io.github.nbplugins.claudecodegui.model.SessionMode.NEW, + null, prepared, oldConfig); + assertSame(prepared, same, "an unchanged store keeps the prepared snapshot"); + + ClaudeSessionController.LaunchIdentity moved = + ClaudeSessionController.reconcileLaunchIdentity( + work.toFile(), + io.github.nbplugins.claudecodegui.model.SessionMode.NEW, + null, prepared, newConfig); + assertEquals(newConfig, moved.configDir()); + assertEquals(Set.of("current"), moved.preStartSessionIds(), + "the snapshot must describe the store the process will write"); + } + + /** + * Two live conversations held by sibling tabs both contain the submitted + * text; the tab must adopt the one written last, which is the conversation + * the process is actually on. Searching an unordered set could pick the + * older sibling instead. + */ + @Test + void borrowedPromptCandidatesAreSearchedNewestFirst() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, false); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "a", Set.of("a")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The older sibling's conversation holds the text first... + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // ...and the newer one holds it too, written strictly later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the live conversation written last is the one on screen"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the same conversation"); + } finally { + tabB.stopProcess(); + tabC.stopProcess(); + } + } + + /** + * A NEW tab submits a recalled prompt and borrows a live sibling's + * conversation while its own file does not exist yet. Once its own file + * does exist, it must win even if the sibling writes again later: the + * borrowed file is established by another live tab, so the claim-based + * exclusion alone (this tab now holds the sibling's file too) would keep + * choosing the sibling. + */ + @Test + void anUnknownPromptPrefersItsOwnNewFileOverTheBorrowedSiblings() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("b")); + + try { + // Up recalls a prompt, Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + appendUserPrompt(dir, "b", "sibling prompt", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the sibling's conversation is borrowed while no own file exists"); + + // This tab's own file appears, then the sibling writes again later. + writeSession(dir, "own", "own"); + appendUserPrompt(dir, "own", "own prompt", START_MS + 11_600L); + setMtime(dir.resolve("own.jsonl"), START_MS + 11_700L); + appendUserPrompt(dir, "b", "sibling again", START_MS + 12_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_700L); + + assertEquals("own", controller.resolveActiveSessionId(), + "the tab's own file must win over the borrowed sibling's"); + assertEquals("own", controller.resolveActiveSessionId(), + "later scans must keep the tab's own file"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Two live tabs launched the same saved conversation {@code a}; this tab + * then resumed {@code b} in the TUI and submits a recalled prompt there. + * The other launcher writes its own human message into {@code a}. A record + * in the launch file is not proof that this tab's submission went there, so + * the strictly newer resumed conversation must win. + */ + @Test + void aSharedLaunchFileDoesNotOverrideAConversationResumedLater() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The TUI resume into b: the conversation is written without any + // prompt from this tab yet, so the scan follows the file time. + appendUserPrompt(dir, "b", "earlier turn", START_MS + 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 5_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the resumed conversation becomes the tracked identity"); + + // Up recalls a prompt and Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The other launcher's own human message lands in the shared file. + appendUserPrompt(dir, "a", "other tab turn", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed conversation, later. + appendUserPrompt(dir, "b", "recalled prompt", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the resumed conversation written last is the one on screen"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A tab launched as a second writer of saved conversation {@code a} + * temporarily adopts a live sibling's conversation {@code c} while its own + * record is not flushed yet. Once its matching record lands in {@code a}, + * the launch file must be eligible again: it is established by another live + * tab, so the ownership exclusion alone would keep the sibling preferred. + */ + @Test + void aSharedLaunchConversationWinsBackAfterATemporaryAdoption() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "c")); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "c")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's conversation holds the text first. + appendUserPrompt(dir, "c", "continue", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + assertEquals("c", controller.resolveActiveSessionId(), + "the sibling's conversation is adopted while no own record exists"); + + // This tab's record lands in the shared launch conversation, later. + appendUserPrompt(dir, "a", "continue", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation's record must take the identity back"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the launch conversation"); + } finally { + tabC.stopProcess(); + tabB.stopProcess(); + } + } + + /** + * A NEW tab has resolved its own conversation {@code a} and a sibling NEW + * tab is borrowing it. Stopping the writer runs the exit callback, which + * releases this tab's claims — the final lookup before the rename must + * still treat {@code a} as this tab's own, or it falls back to an unrelated + * conversation that merely received a user record after the submission, + * even though {@code a} is strictly newer. + */ + @Test + void theFinalStopLookupKeepsTheConversationsTheTabHeld() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("a", "noise")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + + try { + // Up recalls a prompt, Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + appendUserPrompt(dir, "a", "recalled prompt", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the tab's own conversation is adopted"); + + // A sibling NEW tab borrows the resolved conversation. + tabB.simulateProcessStart(START_MS + 12_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // An unrelated conversation also receives a human record after the + // submission; the tab's own file stays strictly newer. + appendUserPrompt(dir, "noise", "unrelated work", START_MS + 12_100L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 12_200L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + Set held = controller.claimedSessionIds(work.toFile(), config); + assertEquals(Set.of("a"), held, "the resolved conversation is held"); + + // The waiter runs the exit callback while the writer is stopping. + controller.simulateProcessExit(); + + assertEquals("a", controller.resolveIdentityWithPreservedClaims(held), + "the final lookup must keep the conversations the tab held"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Two tabs resumed the same saved conversation {@code a}: the first owns + * it, so the second only borrows it even though {@code --resume} named that + * file. Stopping the second tab without submitting a prompt releases the + * borrowed hold, and the final lookup must still keep the launch + * conversation — an unrelated conversation written since the launch must + * not take the rename just because it is unheld. + */ + @Test + void aStoppingTabKeepsTheLaunchConversationItOnlyBorrows() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + // The first tab owns the saved conversation. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // The second tab resumed the same conversation and so only borrows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList) { + @Override + void stopWriter() { + super.stopWriter(); + // The exit waiter releases this tab's claims while the + // writer shuts down, before the final lookup runs. + simulateProcessExit(); + } + }; + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // An unrelated conversation is written after the launch but stays + // older than the launch conversation. + appendUserPrompt(dir, "noise", "unrelated work", START_MS + 10_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 11_000L); + appendUserPrompt(dir, "a", "from the first tab", START_MS + 11_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_000L); + + tabB.stopAndRename("Shared Name"); + + assertEquals("Shared Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation is renamed, not a newer unrelated file"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "noise").customTitle(), + "the unrelated conversation must keep its name"); + } + + /** + * The PTY can exit on its own (Claude quit or crashed) while the tab stays + * open, and the user then closes it with a new name. The exit callback has + * already released this tab's claims, so a conversation it only borrowed is + * no longer evidence by the time Save & Switch resolves the target: it + * must be carried over from the exit, or an unrelated unheld file takes the + * rename just because it is newer. + */ + @Test + void aTabWhoseProcessExitedKeepsItsLaunchConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + // The first tab owns the saved conversation. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // The second tab resumed the same conversation and so only borrows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // An unrelated conversation is written after the launch but stays + // older than the launch conversation. + appendUserPrompt(dir, "noise", "unrelated work", START_MS + 10_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 11_000L); + appendUserPrompt(dir, "a", "from the first tab", START_MS + 11_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_000L); + + // Claude quits on its own while the tab stays open. + tabB.simulateProcessExit(); + + tabB.stopAndRename("Shared Name"); + + assertEquals("Shared Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation is renamed, not a newer unrelated file"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "noise").customTitle(), + "the unrelated conversation must keep its name"); + } + + /** + * A panel send can still be paused between its text and its Enter when the + * user closes the tab. Its Enter never reaches the PTY, but if it completes + * after the writer was stopped, its evidence replaces the last submitted + * prompt before the final identity lookup runs: an unrelated conversation + * that received the same text after that bogus cutoff then takes the + * rename instead of the launch conversation. + */ + @Test + void aPausedSendDoesNotReplaceTheEvidenceOfTheFinalLookup() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + CountDownLatch paused = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference> send = new AtomicReference<>(); + ClaudeSessionController tab = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() throws InterruptedException { + paused.countDown(); + // A stop cancels the queue by interrupting this sender. + release.await(); + } + + @Override + void stopWriter() { + // Let the paused send finish right here: the identity + // snapshot is taken after this method returns. + release.countDown(); + java.util.concurrent.Future queued = send.get(); + if (queued != null) { + try { + queued.get(2, TimeUnit.SECONDS); + } catch (Exception ignored) { + // Cancelled by the stop: the expected outcome. + } + } + super.stopWriter(); + } + }; + tab.simulateProcessStart(START_MS, true); + tab.simulateLiveSession(config, "a", Set.of("a", "noise")); + // The launch conversation holds the last prompt this tab submitted. + tab.simulateGuiSend("hello", START_MS + 2_000L); + tab.completeGuiSend(() -> START_MS + 2_000L); + appendUserPrompt(dir, "a", "hello", START_MS + 2_100L); + setMtime(dir.resolve("a.jsonl"), START_MS + 2_200L); + + // A second panel send is written and pauses before its Enter. + send.set(tab.simulateQueueGuiSend("delayed words", + () -> START_MS + 3_000L, () -> START_MS + 20_000L)); + assertTrue(paused.await(5, TimeUnit.SECONDS), "the second send must pause"); + + // An unrelated conversation receives the same text after the send's + // would-be submission and is newer than the launch conversation. + appendUserPrompt(dir, "noise", "delayed words", START_MS + 20_100L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 20_200L); + + try { + tab.stopAndRename("Closed Name"); + } finally { + tab.stopProcess(); + } + + assertEquals("Closed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation is renamed, not the lookalike file"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "noise").customTitle(), + "the unrelated conversation must keep its name"); + } + + /** + * A terminal Enter during the pause submits the panel line for real. When a + * stop then cancels the sender, the completion that would normally publish + * that submission never runs: the lookup sees only unknown-text evidence + * and, after an in-process resume into a live conversation, falls back to + * the newer user record in the launch file instead of following the + * conversation that actually received the prompt. + */ + @Test + void aTerminalSubmissionDuringThePauseSurvivesTheCancelledSend() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + CountDownLatch paused = new CountDownLatch(1); + AtomicReference> send = new AtomicReference<>(); + long terminalEnterAt = START_MS + 11_100L; + ClaudeSessionController tab = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() throws InterruptedException { + // The user presses Enter in the terminal: the line the + // panel text was written to is submitted for real. + simulateTerminalInput(new byte[]{'\r'}, terminalEnterAt); + paused.countDown(); + // Only the stop's interruption releases this sender. + new CountDownLatch(1).await(); + } + }; + tab.simulateProcessStart(START_MS, true); + // This tab launched the saved conversation a; a sibling tab holds b. + tab.simulateLiveSession(config, "a", Set.of("a", "b")); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // The user resumes b from the prompt panel: the conversation is on + // b, but this tab's identity has not been re-resolved yet. + tab.simulateGuiSend("/resume", START_MS + 10_000L); + tab.completeGuiSend(() -> START_MS + 10_050L); + tab.simulateGuiChoiceAnswer("ARROW:1"); + + // The next panel send is written and pauses before its Enter. + send.set(tab.simulateQueueGuiSend("delayed words", + () -> START_MS + 11_000L, () -> START_MS + 20_000L)); + assertTrue(paused.await(5, TimeUnit.SECONDS), "the second send must pause"); + + // The terminal Enter submitted the panel text into b. The launch + // file receives a later user record, so unknown-text time matching + // would pick it instead of b, which holds the submitted prompt. + appendUserPrompt(dir, "b", "delayed words", START_MS + 11_150L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_200L); + appendUserPrompt(dir, "a", "later work", START_MS + 11_300L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_400L); + + tab.stopAndRename("Closed Name"); + } finally { + tab.stopProcess(); + tabB.stopProcess(); + } + + assertEquals("Closed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the conversation the terminal Enter submitted into is renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation must keep its name"); + } + + /** + * An Up/Down arrow moves the half-second-stale menu cursor off the text + * editor a digit had activated, and the poll does not report it yet: the + * tracked activation must end with the arrow, so the digit naming the + * item the user moved to selects it instead of being typed into an editor + * that no longer has the focus. + */ + @Test + void anArrowLeavingTheActivatedEditorRestoresDigitSelections() { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + // The digit activates the editor, then Up moves the cursor back to + // the ordinary items before the next poll. + controller.simulateTerminalInput(new byte[]{'3'}, START_MS); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 10L); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 20L); + + assertEquals("hello", controller.simulateTerminalInput( + "1hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the digit selects the item the cursor moved to and hello is the next prompt"); + } + + /** + * A draft typed after the terminal Enter that submitted the panel line is + * a new, unsubmitted line: it must not hide the submission when a stop + * cancels the sender. Otherwise the exact panel text is lost as evidence + * and the lookup falls back to the launch conversation's newer record + * instead of following the resumed live conversation that received it. + */ + @Test + void aDraftAfterTheSubmittingEnterDoesNotHideTheSubmission() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + CountDownLatch paused = new CountDownLatch(1); + long terminalEnterAt = START_MS + 11_100L; + ClaudeSessionController tab = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() throws InterruptedException { + simulateTerminalInput(new byte[]{'\r'}, terminalEnterAt); + // The user starts another line without submitting it. + simulateTerminalInput( + "next".getBytes(java.nio.charset.StandardCharsets.UTF_8), + terminalEnterAt + 100L); + paused.countDown(); + // Only the stop's interruption releases this sender. + new CountDownLatch(1).await(); + } + }; + tab.simulateProcessStart(START_MS, true); + tab.simulateLiveSession(config, "a", Set.of("a", "b")); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + tab.simulateGuiSend("/resume", START_MS + 10_000L); + tab.completeGuiSend(() -> START_MS + 10_050L); + tab.simulateGuiChoiceAnswer("ARROW:1"); + + tab.simulateQueueGuiSend("delayed words", + () -> START_MS + 11_000L, () -> START_MS + 20_000L); + assertTrue(paused.await(5, TimeUnit.SECONDS), "the second send must pause"); + + appendUserPrompt(dir, "b", "delayed words", START_MS + 11_150L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_200L); + appendUserPrompt(dir, "a", "later work", START_MS + 11_300L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_400L); + + tab.stopAndRename("Closed Name"); + } finally { + tab.stopProcess(); + tabB.stopProcess(); + } + + assertEquals("Closed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the conversation that received the panel prompt is renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation must keep its name"); + } + + /** + * A sender interrupted by a restart may reach its cleanup only after the + * replacement process recorded input. Its generation is stale by then, and + * the recorder holds the replacement's state: clearing it would erase the + * replacement's draft, so a following panel prompt would be published + * without the text the PTY actually submits. + */ + @Test + void aCancelledSendDoesNotClearTheReplacementRecordersDraft() { + long accepted = controller.simulateProcessGeneration(); + controller.simulateGuiSend("hello", START_MS); + + controller.stopProcess(); + controller.simulateTerminalInput( + "fresh".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L); + + // The interrupted sender's cleanup runs only now. + controller.simulateDiscardGuiSend(accepted); + + assertEquals("fresh suffix", controller.simulateGuiSend(" suffix", START_MS + 200L), + "the replacement's draft must survive the cancelled send's cleanup"); + } + + /** + * Arrows can also enter a text editor: two Downs from the first item land + * on `Type something.`, and the poll does not report it yet. The digits + * typed in that window belong to the editor, so they must not retire the + * menu and turn the rest of the answer into prompt evidence. + */ + @Test + void arrowsEnteringTheEditorMakeTheFollowingDigitsItsText() { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'B'}, START_MS + 10L); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'B'}, START_MS + 20L); + + assertNull(controller.simulateTerminalInput( + "1feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the cursor is in the editor the arrows reached; the answer is not a prompt"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + } + + /** + * A sender blocked in a native write to a hung process cannot be released + * by interrupting its thread, and Save & Switch waits for it on the EDT. + * The PTY child is killed before the wait, so the write returns and the + * stop completes instead of freezing the IDE. + */ + @Test + void stopAndRenameUnblocksAWriteStuckOnAHungProcess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "a", Set.of("a")); + + CountDownLatch writing = new CountDownLatch(1); + CountDownLatch released = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + writing.countDown(); + // A hung process never drains the pipe: the interrupt that + // cancels the queue does not end this write. + while (true) { + try { + released.await(); + return; + } catch (InterruptedException ignored) { + // the native write ignores it too + } + } + }); + // Only killing the child releases the blocked write. + controller.simulatePtyKill(released::countDown); + controller.simulateQueueGuiSend("delayed words", + () -> START_MS + 10L, () -> START_MS + 20L); + assertTrue(writing.await(5, TimeUnit.SECONDS), "the send must reach its write"); + + java.util.concurrent.ExecutorService stopper = + java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + java.util.concurrent.Future stopped = stopper.submit( + () -> controller.stopAndRename("Closed Name")); + stopped.get(10, TimeUnit.SECONDS); + } finally { + released.countDown(); + stopper.shutdownNow(); + controller.stopProcess(); + } + + assertEquals("Closed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the stop completes and still names the launched session"); + } + + /** + * A switch command submitted by a terminal Enter during the pause — the + * panel's untouched line — has switched the conversation. A later terminal + * submission of a recalled prompt replaces the text evidence, but the + * switch itself happened, and the stop-time lookup has to follow it to the + * conversation the command cleared into rather than the launch one. + */ + @Test + void aSubmittedSwitchSurvivesALaterRecalledSubmission() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + CountDownLatch paused = new CountDownLatch(1); + ClaudeSessionController tab = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() { + // The terminal submits the untouched /clear, then the + // user recalls an earlier prompt and submits it too. + simulateTerminalInput(new byte[]{'\r'}, START_MS + 11_000L); + simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 11_050L); + simulateTerminalInput(new byte[]{'\r'}, START_MS + 11_100L); + paused.countDown(); + } + }; + + try { + tab.simulateProcessStart(START_MS, true); + tab.simulateLiveSession(config, "a", Set.of("a")); + tab.simulateQueueGuiSend("/clear", + () -> START_MS + 10_000L, () -> START_MS + 11_200L); + assertTrue(paused.await(5, TimeUnit.SECONDS), "the send must pause"); + + // The launch conversation keeps receiving records, but the + // conversation /clear opened is strictly newer. + appendUserPrompt(dir, "a", "later work", START_MS + 11_250L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_300L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "recalled words", START_MS + 11_350L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_400L); + + tab.stopAndRename("Cleared Title"); + } finally { + tab.stopProcess(); + } + + assertEquals("Cleared Title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the conversation the submitted switch opened is renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation keeps its name"); + } + + /** + * A GUI answer typed into a menu's text editor is forwarded with a delay: + * the TUI keeps the menu open until its Enter. A typed answer submitted + * with Enter in that window answers the same editor, so it is not a prompt + * and must not become prompt evidence. + */ + @Test + void aTypedAnswerDuringTheTypeAnswerIsNotAPrompt() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + controller.writePtyAnswer("TYPE:3:auto answer"); + + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the TUI is still on its editor: the typed answer is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the menu is retired once the answer has been forwarded"); + } + + /** + * A NEW tab temporarily borrows a live sibling's conversation because both + * submitted the same text before its own file was flushed. The borrowed + * file is the sibling's, so the final stop lookup must not preserve it as + * this tab's own: once this tab's own file is flushed during the stop, that + * file is the answer even if the sibling's conversation is currently + * tracked. + */ + @Test + void aBorrowedConversationIsNotPreservedForTheFinalStopLookup() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's conversation holds the text first. + appendUserPrompt(dir, "b", "continue", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the sibling's conversation is borrowed while no own file exists"); + + // This tab's own file is flushed during the stop, strictly newest. + appendUserPrompt(dir, "a", "continue", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + Set held = controller.claimedSessionIds(work.toFile(), config); + assertEquals(Set.of(), held, + "a borrowed conversation is not this tab's own file"); + + controller.simulateProcessExit(); + + assertEquals("a", controller.resolveIdentityWithPreservedClaims(held), + "the tab's own file must win over the borrowed one"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A launch-known tab temporarily adopted a sibling's conversation {@code c} + * because the sibling flushed the identical prompt first. Once the + * conversation the tab actually joined holds the submission and is strictly + * newer, the borrowed guess must not stay preferred: the newest-first + * candidate order has to decide, or Save & Switch renames the sibling's + * conversation. + */ + @Test + void aBorrowedGuessDoesNotPinALaunchKnownTabToTheOlderConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b", "c")); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "b", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's conversation holds the text first. + appendUserPrompt(dir, "c", "continue", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + assertEquals("c", controller.resolveActiveSessionId(), + "the identical sibling prompt is adopted while nothing else matches"); + + // The conversation this tab actually joined gets its record later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the borrowed guess must not beat the strictly newer joined file"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the joined conversation"); + } finally { + tabC.stopProcess(); + tabB.stopProcess(); + } + } + + /** + * A resolved own file that another live tab also writes is not proof of + * this tab's submission: that tab's concurrent turn appends a user record + * of its own. It must compete with the other shared candidates by recency, + * so a conversation the run actually joined and that is strictly newer + * wins, instead of the own priority returning the shared file. + */ + @Test + void anOwnFileSharedWithAnotherWriterCompetesWithTheSharedCandidates() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the first-file priority resolves the run's own conversation"); + + // A second tab resumes the resolved conversation, and a third one + // writes the conversation this tab switches to. + tabB.simulateProcessStart(START_MS + 12_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + tabC.simulateProcessStart(START_MS + 12_000L, true); + tabC.simulateLiveSession(config, "b", Set.of("a", "b")); + + // A recalled prompt is submitted; the second tab's concurrent turn + // appends a human record to the shared own file, while the actual + // submission lands in b and is strictly newer. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 21_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 21_001L)); + + appendUserPrompt(dir, "a", "other tab turn", START_MS + 21_100L); + setMtime(dir.resolve("a.jsonl"), START_MS + 21_200L); + appendUserPrompt(dir, "b", "recalled prompt", START_MS + 21_300L); + setMtime(dir.resolve("b.jsonl"), START_MS + 21_400L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared own file must not beat the strictly newer joined file"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the joined conversation"); + + Set held = controller.claimedSessionIds(work.toFile(), config); + controller.simulateProcessExit(); + assertEquals("b", controller.resolveIdentityWithPreservedClaims(held), + "the final lookup must make the same comparison"); + } finally { + tabC.stopProcess(); + tabB.stopProcess(); + } + } + + /** + * The destination of a shared launch-file match may be a saved conversation + * the user resumed in the TUI that no live tab holds yet: a record written + * strictly later with the same text is then the conversation on screen. + */ + @Test + void aSharedLaunchFileMatchYieldsToAStrictlyNewerUnclaimedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The other launcher's own turn submits the same literal text into + // the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared launch match must not beat the strictly newer file"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The conversation can also be switched from the GUI alone — a slash + * command such as {@code /resume} sent from the prompt panel and the + * picker answer — and those writes are made by the plugin, so no raw + * terminal input marks the switch. A shared launch-file match must then + * yield to the strictly newer conversation exactly as it does after + * typing in the terminal. + */ + @Test + void aSharedLaunchFileMatchYieldsToAGuiDrivenSwitch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user switches the conversation from the GUI: /resume from + // the prompt panel, then the session picked from the menu. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // The new conversation is not followed by the identity poll yet + // when the next prompt is sent. + controller.simulateSentPrompt("continue", START_MS + 11_000L); + + // The other launcher's own turn submits the same literal text into + // the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "a GUI-driven switch must not leave the launch conversation"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A conversation switch made from the GUI can land in a saved conversation + * no live tab holds. A recalled prompt leaves only user records as + * evidence, and a concurrent turn of the writer remaining on the shared + * launch file must not end the search: the strictly newer destination + * holds this tab's submission. + */ + @Test + void anUnknownPromptFollowsAGuiSwitchIntoAnUnclaimedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user switches the conversation from the GUI: /resume from + // the prompt panel, then the session picked from the menu. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // A recalled prompt: its text is unknown. + controller.simulatePromptSubmittedAt(START_MS + 11_000L); + controller.simulatePromptText(null); + + // The other launcher's own turn lands in the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, strictly later. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared launch record must not hide the unclaimed destination"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The panel writes /resume, then the user replaces the line during the + * pause: Ctrl+U clears it and hello is typed and submitted instead. The + * Enter does not switch the conversation, so the discarded command must + * not stay as switch evidence — otherwise the resolver's switch recovery + * adopts an unrelated newer unheld record instead of the launch + * conversation that received hello. + */ + @Test + void aSwitchCommandEditedAwayDoesNotSwitchTheConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + // This tab and a second one launched the same saved conversation, so + // only one of them owns it. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "noise")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "noise")); + + try { + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.simulateTerminalInput(new byte[]{0x15}, START_MS + 10_050L); + controller.simulateTerminalInput( + "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_060L); + controller.completeGuiSend(() -> START_MS + 10_100L); + + // hello landed in the launch conversation. + appendUserPrompt(dir, "a", "hello", START_MS + 10_150L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_250L); + // An unrelated conversation received hello later and is newer. + appendUserPrompt(dir, "noise", "hello", START_MS + 10_160L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 10_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "an edited-away switch must leave the launch conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The panel text reaches the conversation only when the GUI send's Enter is + * written. Publishing it as own-session evidence sooner would let an + * identity scan running in the pause adopt another conversation that + * receives the same prompt before this process has submitted anything. + * The cutoff is that Enter's time, not the text-write time: a record + * written in the pause must not count as this submission. + */ + @Test + void aGuiPromptBecomesEvidenceOnlyWhenItsEnterIsWritten() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + controller.simulateGuiSend("hello", START_MS); + + assertEquals("earlier", controller.simulateLastPromptText(), + "the panel text is not submitted until its Enter"); + assertEquals(START_MS - 10_000L, controller.simulatePromptSubmittedAtValue(), + "the previous submission stays the evidence meanwhile"); + + controller.completeGuiSend(() -> START_MS + 200L); + + assertEquals("hello", controller.simulateLastPromptText(), + "the Enter makes the panel text the submitted prompt"); + assertEquals(START_MS + 200L, controller.simulatePromptSubmittedAtValue(), + "the Enter time is the submission cutoff, not the send time"); + } + + /** + * A Tab-completed or recalled draft holds only a part of the submitted + * line, so a GUI submission carrying it is recorded as unknown text + * instead of an exact prompt the conversation never received. Like exact + * text, it is not published before the Enter is written: an identity scan + * running in the pause could otherwise adopt an unrelated conversation by + * time alone. Its cutoff is the Enter time, not the send time: with no + * text to match, a record written during the pause would otherwise count + * as this submission. + */ + @Test + void aGuiSendWithACompletedDraftRecordsUnknownText() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + controller.simulateTerminalInput( + "@fi".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 1_000L); + + assertEquals("@fi hello", controller.simulateGuiSend(" hello", START_MS + 2_000L), + "the composed line is still what the conversation receives"); + assertEquals("earlier", controller.simulateLastPromptText(), + "nothing is published before the Enter is written"); + assertEquals(START_MS - 10_000L, controller.simulatePromptSubmittedAtValue(), + "the previous evidence is kept until the actual submission"); + + controller.completeGuiSend(() -> START_MS + 2_300L); + assertNull(controller.simulateLastPromptText(), + "a completed draft makes the submitted text unknown"); + assertEquals(START_MS + 2_300L, controller.simulatePromptSubmittedAtValue(), + "the Enter time is the submission cutoff, not the send time"); + } + + /** + * The reconciliation resets the recall/completion flags, so a terminal + * Enter during the pause sees an empty line and is classified as an empty + * Enter, although it submits the composed line. The pending unknown-text + * submission must then be published at that Enter's time: skipping it + * leaves the previous prompt as the evidence and the stop-time lookup + * renames the wrong conversation. + */ + @Test + void anEarlyEnterPublishesTheUnknownTextOfACompletedDraft() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + controller.simulateTerminalInput( + "@fi".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 1_000L); + controller.simulateGuiSend(" hello", START_MS + 2_000L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 2_100L); + controller.completeGuiSend(() -> START_MS + 2_400L); + + assertNull(controller.simulateLastPromptText(), + "the completed draft makes the submitted text unknown"); + assertEquals(START_MS + 2_100L, controller.simulatePromptSubmittedAtValue(), + "the early Enter's time is the submission cutoff"); + } + + /** + * A GUI send extending a recalled draft is unknown-text evidence, and a + * terminal Enter during the pause submits that line. Up then recalls a + * second prompt: the recorder holds no text for that line either, yet the + * delayed Enter submits it. The submission must be recorded as unknown + * text at the delayed Enter, not dropped as if the Enter did nothing — + * otherwise the previous prompt stays the evidence with its old timestamp. + */ + @Test + void aRecalledDraftAfterTheEarlyEnterIsRecordedAsUnknownSubmission() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + // Up recalls a prompt on the input line: only part of it is recorded. + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS); + controller.simulateGuiSend(" hello", START_MS + 10L); + // The terminal Enter submits the composed line; Up recalls another + // prompt on the new line, which leaves nothing in the recorder. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 50L); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 100L); + + controller.completeGuiSend(() -> START_MS + 250L); + + assertNull(controller.simulateLastPromptText(), + "a recalled line has no recorded text to match"); + assertEquals(START_MS + 250L, controller.simulatePromptSubmittedAtValue(), + "the delayed Enter submits the recalled line"); + } + + /** + * A terminal Enter during the pause submits the panel text; a draft typed + * after it starts a new line, which the delayed Enter then submits. That + * draft is fully recorded, so it becomes the evidence with the Enter time + * instead of the delayed Enter being treated as a no-op and the second + * submission going unrecorded. + */ + @Test + void aDraftTypedAfterTheEarlyEnterIsSubmittedByTheDelayedEnter() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + controller.simulateGuiSend("hello", START_MS); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 50L); + controller.simulateTerminalInput( + "next".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 100L); + + controller.completeGuiSend(() -> START_MS + 250L); + + assertEquals("next", controller.simulateLastPromptText(), + "the delayed Enter submits the draft typed after the early Enter"); + assertEquals(START_MS + 250L, controller.simulatePromptSubmittedAtValue(), + "the delayed Enter's time is the submission cutoff"); + } + + /** + * A switch driven from the terminal (a resume picked with the keyboard) + * must be followed for an unknown prompt as well: the shared launch file's + * concurrent record must not hide the unclaimed destination. + */ + @Test + void anUnknownPromptFollowsATerminalSwitchIntoAnUnclaimedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user resumes the saved conversation b from the terminal and + // submits a prompt recalled from the history. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 9_000L); + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 10_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_000L)); + + // The other launcher's own turn lands in the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, strictly later. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "a terminal-driven switch must follow the unclaimed destination"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * An unheld file is not proof on its own: a tab that submitted its own + * prompt and exited leaves one behind, and it must not replace the shared + * conversation on screen when it was written earlier. + */ + @Test + void anUnheldOlderRecordDoesNotDisplaceTheSharedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + // The live sibling on b. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b", "c")); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + try { + // The user switches to the live conversation b from the GUI. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // A recalled prompt: its text is unknown. + controller.simulatePromptSubmittedAt(START_MS + 11_000L); + controller.simulatePromptText(null); + + // A tab that submitted its own prompt and exited leaves an unheld + // record behind, earlier than this tab's submission. + appendUserPrompt(dir, "c", "third", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + // The live conversation holds the actual submission, strictly + // later. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "an older unheld record must not displace the shared conversation"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the shared conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A conversation this tab resolved after a GUI switch stays selected. The + * switch marker lasts until the run ends, so a later recalled prompt in + * that own conversation must not be replaced by a live sibling that merely + * appended a newer human record. + */ + @Test + void aResolvedOwnConversationSurvivesALaterGuiSwitchSearch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + // The live sibling on c. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "b", "c")); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + try { + // The user switches the conversation from the GUI. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // A literal prompt resolves the unclaimed destination b as this + // tab's own conversation. + controller.simulateSentPrompt("continue", START_MS + 11_000L); + appendUserPrompt(dir, "b", "continue", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId()); + + // A recalled prompt: its text is unknown. The own conversation + // receives the submission, but the sibling appends a newer record. + controller.simulatePromptSubmittedAt(START_MS + 12_000L); + controller.simulatePromptText(null); + appendUserPrompt(dir, "b", "recalled", START_MS + 12_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_500L); + appendUserPrompt(dir, "c", "sibling", START_MS + 12_600L); + setMtime(dir.resolve("c.jsonl"), START_MS + 13_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the resolved own conversation must beat a sibling's later record"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the own conversation"); + } finally { + tabC.stopProcess(); + } + } + + /** + * A launch file that another live tab also writes is not proof that this + * tab's submission went there: that tab's own turn can submit the same + * literal text. A live conversation holding the same text and written + * strictly later must win instead of the launch file ending the search. + */ + @Test + void aSharedLaunchFileMatchYieldsToAStrictlyNewerLiveConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + // The conversation this tab resumes in the TUI is live as well. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The other launcher's own turn submits the same literal text into + // the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed conversation, later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared launch match must not beat the strictly newer live file"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabC.stopProcess(); + tabB.stopProcess(); + } + } + + /** + * A NEW run's resolved conversation must stay eligible even after a later + * recalled submission temporarily borrowed a live sibling's file: adopting + * the sibling releases the claim on the run's own file, so the final lookup + * must keep it as a candidate rather than considering only the borrowed + * sibling. Once the run's own file holds the submission and is strictly + * newer, it is the answer. + */ + @Test + void aResolvedNewConversationStaysEligibleAfterABorrowedGuess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the first-file priority resolves the run's own conversation"); + + // A recalled prompt is submitted; the live sibling writes its own + // record first, so the run temporarily borrows the sibling's file. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 21_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 21_001L)); + + appendUserPrompt(dir, "b", "sibling turn", START_MS + 21_100L); + setMtime(dir.resolve("b.jsonl"), START_MS + 21_200L); + assertEquals("b", controller.resolveActiveSessionId(), + "the sibling's file is borrowed while the own file is not written yet"); + + // The run's own file receives the submission, strictly newer. + appendUserPrompt(dir, "a", "recalled prompt", START_MS + 21_300L); + setMtime(dir.resolve("a.jsonl"), START_MS + 21_400L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the run's own file must win once it holds the submission"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the run's own file"); + + Set held = controller.claimedSessionIds(work.toFile(), config); + controller.simulateProcessExit(); + assertEquals("a", controller.resolveIdentityWithPreservedClaims(held), + "the final lookup must keep the run's own file eligible"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A NEW run resolved its own conversation for the first submission. When it + * later joins a live sibling's conversation and submits a recalled prompt + * (unknown text), the first-file priority is spent: an exited tab's + * unrelated unclaimed file must not be adopted ahead of the shared + * conversation that holds the submission, even though that file is newer + * than nothing else considered. + */ + @Test + void theFirstFilePriorityEndsAfterANewRunResolvedItsOwnFile() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, null, Set.of("a", "b", "c")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // This run creates its own conversation for the first submission. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the first-file priority resolves the run's own conversation"); + + // A recalled prompt is submitted after the TUI joined b. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 21_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 21_001L)); + + // An unrelated exited tab's conversation also received a human + // record; the joined conversation gets the actual submission and + // is strictly newer. + appendUserPrompt(dir, "c", "other work", START_MS + 21_600L); + setMtime(dir.resolve("c.jsonl"), START_MS + 21_700L); + appendUserPrompt(dir, "b", "recalled prompt", START_MS + 21_900L); + setMtime(dir.resolve("b.jsonl"), START_MS + 22_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared conversation holding the submission must win"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the shared conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A NEW tab resumes another live tab's conversation and only then submits + * a recalled prompt, before creating a file of its own. An exited tab's + * unrelated unclaimed record written after the submission must not be + * taken as this run's first file: the resumed conversation holds the + * actual submission and was written strictly later. + */ + @Test + void aNewTabResumingALiveConversationDoesNotAdoptAnExitedTabsRecord() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "c", "c"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // Another live tab is writing conversation b. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("b", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, null, Set.of("b", "c")); + + try { + // The user resumes the live conversation b from the GUI. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // A recalled prompt: its text is unknown. + controller.simulatePromptSubmittedAt(START_MS + 11_000L); + controller.simulatePromptText(null); + + // A third tab submits to c and exits: unclaimed, but unrelated. + appendUserPrompt(dir, "c", "other work", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + // The actual submission lands in the resumed conversation b, which + // is strictly newer. + appendUserPrompt(dir, "b", "recalled prompt", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the resumed conversation must win over an exited tab's record"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The same race with known prompt text. Once the tab has borrowed a + * sibling's conversation, the launch-known preference must not keep it + * there: the borrowed file is owned by another live tab, so its own launch + * conversation — which now holds the submitted text and is strictly newer — + * must take the identity back. + */ + @Test + void aKnownPromptRecoversTheLaunchConversationAfterASharedGuess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's identical prompt lands first. + appendUserPrompt(dir, "b", "continue", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "with no record of its own yet, the tab follows the sibling"); + + // This tab's submission lands in its launch conversation, later. + appendUserPrompt(dir, "a", "continue", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation's record must take the identity back"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the recovered conversation"); + assertEquals("b", tabB.resolveActiveSessionId(), + "the sibling keeps its own conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * An unresolved NEW tab submits a Tab-completed prompt, so its text is + * unknown, while a live sibling submits its own human prompt. The sibling + * holds the only claimed file, but this tab's own new file also contains a + * user record for its submission: taking the sibling's record as a switch + * would pin this tab to the sibling's conversation. + */ + @Test + void anUnknownPromptPrefersTheTabsOwnNewSessionOverASiblingTurn() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "sibling", "sibling"); + setMtime(dir.resolve("sibling.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("sibling")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "sibling", Set.of("sibling")); + + try { + // Tab completes the prompt, Enter submits it: the text is unknown. + controller.simulateTerminalInput( + "inspect @REA".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 11_001L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_002L)); + + // This tab's own new conversation gets the submission ... + writeSession(dir, "own-new", "own-new"); + appendUserPrompt(dir, "own-new", "inspect @README.md", START_MS + 11_500L); + setMtime(dir.resolve("own-new.jsonl"), START_MS + 13_000L); + // ... while the sibling submits an unrelated prompt of its own. + appendUserPrompt(dir, "sibling", "sibling turn", START_MS + 11_400L); + setMtime(dir.resolve("sibling.jsonl"), START_MS + 12_000L); + + assertEquals("own-new", controller.resolveActiveSessionId(), + "this tab's own record must beat the sibling's own turn"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A file with second-rounded mtimes can hold the complete submitted prompt + * yet sit just below the exact cutoff. A live sibling submitting identical + * text must not then be adopted: the relaxed cutoff has to be applied to + * this tab's own candidates before the borrowed match is accepted. + */ + @Test + void aRoundedOwnFileStillWinsOverASiblingWithTheSamePrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "sibling", "sibling"); + setMtime(dir.resolve("sibling.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("sibling")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "sibling", Set.of("sibling")); + + try { + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L), + "the submitted prompt is recorded exactly"); + + // The own file holds the prompt but its rounded mtime is below the + // submission; the sibling submitted the same text afterwards. + writeSession(dir, "own-new", "own-new"); + appendUserPrompt(dir, "own-new", "hello", START_MS + 11_000L); + setMtime(dir.resolve("own-new.jsonl"), START_MS + 10_500L); + appendUserPrompt(dir, "sibling", "hello", START_MS + 11_200L); + setMtime(dir.resolve("sibling.jsonl"), START_MS + 12_000L); + + assertEquals("own-new", controller.resolveActiveSessionId(), + "the own record must be found before the borrowed match"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Stopping releases this tab's claims, which also erases the fact that a + * conversation was only borrowed. The final post-stop lookup must still + * not return that borrowed conversation: another live tab holds it, so the + * launch conversation flushed strictly later during the stop is the one + * being renamed. + */ + @Test + void aBorrowedConversationReleasedAtStopDoesNotWinTheFinalLookup() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + // A NEW sibling resolves the conversation it created and holds it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, null, Set.of("a")); + + try { + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + assertEquals("b", tabB.resolveActiveSessionId(), + "the sibling resolves the conversation it created"); + + // Both tabs submit the same prompt: this tab can only borrow the + // sibling's file, which already holds the text. + controller.simulateGuiSend("continue", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_200L); + assertEquals("b", controller.resolveActiveSessionId(), + "the identical prompt makes this tab follow the sibling"); + + Set held = controller.claimedSessionIds(work.toFile(), config); + assertFalse(held.contains("b"), + "the sibling's conversation is only borrowed and is not carried over"); + + // The writer exits, releasing the borrowed classification, and the + // launch conversation is flushed strictly later: the rename must + // target it, not the borrowed file another tab still holds. + controller.simulateProcessExit(); + appendUserPrompt(dir, "a", "continue", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 13_000L); + + assertEquals("a", controller.resolveIdentityWithPreservedClaims(held), + "the launch conversation flushed during the stop must be renamed"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The GUI submits a prompt with two writes separated by a pause. Keys typed + * within that pause are part of the line the Enter submits, but the TUI + * Enter itself never reaches the parser (the plugin marks its own writes). + * They must not stay recorded as the draft of the next prompt, and the + * panel text must not pass as the exact submitted text. + */ + @Test + void keysTypedWhileTheGuiPromptIsBeingSubmittedDoNotBecomeTheNextPrompt() { + controller.simulateGuiSend("panel text", START_MS); + + // Keys typed while the panel text is being submitted. + controller.simulateTerminalInput( + " and typed".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L); + controller.completeGuiSend(); + + assertNull(controller.simulateLastPromptText(), + "the line the Enter submitted is not the panel text alone"); + assertEquals("next", + controller.simulateTerminalInput( + "next\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 300L), + "keys from the previous submission must not stay in the draft"); + } + + /** + * A nonprinting edit during the GUI send's pause changes the TUI line + * without leaving anything in the recorded draft: Backspace on the freshly + * reset recorder deletes a character of the panel text. The submitted text + * is then no longer the composed evidence. + */ + @Test + void aNonprintingEditDuringTheGuiSendMakesItsTextUnknown() { + controller.simulateGuiSend("hello", START_MS); + + // Backspace during the pause: the TUI line becomes "hell". + controller.simulateTerminalInput(new byte[]{0x7f}, START_MS + 100L); + controller.completeGuiSend(); + + assertNull(controller.simulateLastPromptText(), + "the line the Enter submits is no longer the panel text"); + } + + /** + * The child can persist the submitted prompt as soon as it reads the Enter, + * so the submission cutoff must be sampled before the bytes are forwarded. + * Sampling it after the write makes the cutoff later than the user record + * and the evidence lookup rejects the conversation's own file. + */ + @Test + void theGuiPromptIsTimestampedBeforeTheEnterIsWritten() { + long[] writeStarted = new long[1]; + controller.simulatePtyWrite(bytes -> { + writeStarted[0] = System.currentTimeMillis(); + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + controller.simulateGuiSend("hello", START_MS + 1L); + controller.simulateTerminalInput( + " world".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L); + controller.completeGuiSend(); + + assertTrue(writeStarted[0] > 0L, "the Enter must be written"); + assertTrue(controller.simulatePromptSubmittedAtValue() <= writeStarted[0], + "the submission time must be captured before the Enter is forwarded"); + } + + /** + * If the user submits the line from the terminal during the GUI send's + * pause, the panel's Enter only submits an empty line afterwards. That + * later, no-op Enter must not re-time the submission: the cutoff would + * then be later than the user record the child wrote for the terminal + * Enter. + */ + @Test + void aTerminalEnterDuringTheGuiSendKeepsTheFirstSubmissionTime() { + controller.simulateGuiSend("hello", START_MS); + + // The terminal submits the line the panel text was written to. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.completeGuiSend(); + + assertEquals("hello", controller.simulateLastPromptText(), + "the terminal Enter submitted exactly the panel text"); + assertEquals(START_MS + 100L, controller.simulatePromptSubmittedAtValue(), + "the terminal Enter's time is the cutoff; the panel's later Enter " + + "must not re-time the submission"); + } + + /** + * An edit followed by a terminal Enter during the GUI send's pause submits + * a line that is no longer the panel text. The recorder sees only the empty + * buffer (the panel text was never recorded), so the submission must be + * classified as unknown at the Enter itself: until {@code completeGuiSend} + * runs, the identity scan would otherwise still match the panel text as + * exact evidence. + */ + @Test + void anEditedGapEnterInvalidatesTheExactEvidenceImmediately() { + controller.simulateGuiSend("hello", START_MS); + + // Backspace makes the TUI line "hell"; Enter submits it before the + // panel's own Enter. + controller.simulateTerminalInput(new byte[]{0x7f}, START_MS + 100L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 200L); + + assertNull(controller.simulateLastPromptText(), + "the edit made the submitted line differ from the panel text"); + assertEquals(START_MS + 200L, controller.simulatePromptSubmittedAtValue(), + "the terminal Enter's time is the submission time"); + + controller.completeGuiSend(); + assertNull(controller.simulateLastPromptText(), + "the panel's no-op Enter must keep the submission unknown"); + } + + /** + * The panel text must be on the TUI line before terminal input can be + * processed against the reconciled recorder. Otherwise an Enter arriving + * between the reconciliation and the text write submits only the terminal + * draft, while the recorded evidence claims the composed line was + * submitted. + */ + @Test + void terminalInputWaitsForTheGuiTextWrite() throws Exception { + CountDownLatch writeStarted = new CountDownLatch(1); + CountDownLatch releaseWrite = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + writeStarted.countDown(); + try { + releaseWrite.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + controller.simulateTerminalInput( + "draft".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + + AtomicReference failure = new AtomicReference<>(); + Thread sender = new Thread(() -> { + try { + controller.simulateGuiSendWithText("hello", START_MS + 1L); + } catch (Throwable e) { + failure.set(e); + } + }); + sender.start(); + assertTrue(writeStarted.await(5, TimeUnit.SECONDS), + "the panel text must be written"); + + CountDownLatch enterDone = new CountDownLatch(1); + Thread enter = new Thread(() -> { + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 2L); + enterDone.countDown(); + }); + enter.start(); + assertFalse(enterDone.await(400, TimeUnit.MILLISECONDS), + "terminal input must wait until the panel text is on the TUI line"); + releaseWrite.countDown(); + sender.join(5_000L); + enter.join(5_000L); + + assertNull(failure.get(), "the panel text write must not fail"); + assertEquals(0L, enterDone.getCount(), + "terminal input must be processed after the write"); + } + + /** + * The prompt panel accepts a second send while the previous one is still + * in its pause. Writing the second text before the first Enter would put + * both prompts on one TUI line, which neither pending record describes, so + * the evidence would name only the second text. The second send must wait + * until the first line is submitted. + */ + @Test + void aSecondGuiSendWaitsForTheFirstSendToBeSubmitted() throws Exception { + List writes = Collections.synchronizedList(new ArrayList<>()); + controller.simulatePtyWrite(bytes -> writes.add( + new String(bytes, java.nio.charset.StandardCharsets.UTF_8))); + + AtomicReference failure = new AtomicReference<>(); + CountDownLatch firstPaused = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + Thread first = new Thread(() -> { + try { + controller.simulateSendPrompt("hello", () -> START_MS, + () -> START_MS + 200L, () -> { + firstPaused.countDown(); + try { + releaseFirst.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } catch (Throwable e) { + failure.set(e); + } + }, "first-gui-send"); + first.start(); + assertTrue(firstPaused.await(5, TimeUnit.SECONDS), + "the first text must be written"); + + CountDownLatch secondAtPause = new CountDownLatch(1); + Thread second = new Thread(() -> { + try { + controller.simulateSendPrompt("world", () -> START_MS + 50L, + () -> START_MS + 250L, secondAtPause::countDown); + } catch (Throwable e) { + failure.set(e); + } + }, "second-gui-send"); + second.start(); + assertFalse(secondAtPause.await(400, TimeUnit.MILLISECONDS), + "the second send must wait until the first line is submitted"); + + releaseFirst.countDown(); + first.join(5_000L); + second.join(5_000L); + + assertNull(failure.get(), "the sends must not fail"); + assertEquals(List.of("hello", "\r", "world", "\r"), writes, + "each text is written separately and followed by its own Enter"); + assertEquals("world", controller.simulateLastPromptText(), + "the last submission is the second prompt"); + } + + /** + * A send queued behind another one belongs to the run that accepted it. If + * the tab is restarted while it waits, it must not write the prompt through + * the replacement process's connector, where it would execute in a + * conversation the user did not type it for. Once the first send's text + * went to the closed process, its delayed Enter must not be delivered to + * the replacement either. + */ + @Test + void aQueuedGuiSendIsDroppedWhenTheTabRestartsBeforeItWrites() throws Exception { + List writes = Collections.synchronizedList(new ArrayList<>()); + controller.simulatePtyWrite(bytes -> writes.add( + new String(bytes, java.nio.charset.StandardCharsets.UTF_8))); + + AtomicReference failure = new AtomicReference<>(); + CountDownLatch firstPaused = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + Thread first = new Thread(() -> { + try { + controller.simulateSendPrompt("hello", () -> START_MS, + () -> START_MS + 200L, () -> { + firstPaused.countDown(); + try { + releaseFirst.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } catch (Throwable e) { + failure.set(e); + } + }, "first-gui-send"); + first.start(); + assertTrue(firstPaused.await(5, TimeUnit.SECONDS), + "the first text must be written"); + + Thread second = new Thread(() -> { + try { + controller.simulateSendPrompt("world", () -> START_MS + 50L, + () -> START_MS + 250L, () -> { }); + } catch (Throwable e) { + failure.set(e); + } + }, "second-gui-send"); + second.start(); + // BLOCKED means the sender has already captured the generation it was + // accepted for and is waiting for the first send to release the lock. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (second.getState() != Thread.State.BLOCKED + && System.nanoTime() < deadline) { + Thread.sleep(10L); + } + assertEquals(Thread.State.BLOCKED, second.getState(), + "the second send must be queued behind the first one"); + + // The user stops the session and starts another one while both sends + // are still open. + controller.stopProcess(); + releaseFirst.countDown(); + first.join(5_000L); + second.join(5_000L); + + assertNull(failure.get(), "the sends must not fail"); + assertEquals(List.of("hello"), writes, + "neither the queued prompt nor a stale Enter may reach the replacement process"); + } + + /** + * Three sends accepted in quick succession must reach the TUI in that + * order. Giving each send its own thread that competes for the send lock + * offers no FIFO guarantee — a monitor may hand the lock to either waiter + * — so the prompts could be written swapped, and a slash command and the + * instruction it was meant to precede could run in the wrong order. + */ + @Test + void queuedGuiSendsAreDeliveredInAcceptanceOrder() throws Exception { + List writes = Collections.synchronizedList(new ArrayList<>()); + java.util.Set sendThreads = java.util.Collections.synchronizedSet( + java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap())); + + CountDownLatch firstPaused = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicBoolean first = + new java.util.concurrent.atomic.AtomicBoolean(true); + controller = new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() throws InterruptedException { + if (first.compareAndSet(true, false)) { + firstPaused.countDown(); + releaseFirst.await(5, TimeUnit.SECONDS); + } + } + }; + controller.simulatePtyWrite(bytes -> { + writes.add(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + sendThreads.add(Thread.currentThread()); + }); + + java.util.concurrent.Future firstSend = controller.simulateQueueGuiSend("first"); + java.util.concurrent.Future secondSend = controller.simulateQueueGuiSend("second"); + java.util.concurrent.Future thirdSend = controller.simulateQueueGuiSend("third"); + + assertTrue(firstPaused.await(5, TimeUnit.SECONDS), + "the first text must be written"); + releaseFirst.countDown(); + firstSend.get(5, TimeUnit.SECONDS); + secondSend.get(5, TimeUnit.SECONDS); + thirdSend.get(5, TimeUnit.SECONDS); + + assertEquals(List.of("first", "\r", "second", "\r", "third", "\r"), + new ArrayList<>(writes), + "the prompts must be submitted in the order they were accepted"); + assertEquals(1, sendThreads.size(), + "the sends must be delivered by one ordered worker, not competing threads"); + } + + /** Starts {@code /bin/cat} in a PTY for a test that needs a real connector. */ + private static com.pty4j.PtyProcess startEchoPty() throws Exception { + return new com.pty4j.PtyProcessBuilder(new String[]{"/bin/cat"}) + .setEnvironment(java.util.Map.of("TERM", "xterm")) + .setDirectory(System.getProperty("java.io.tmpdir")) + .setConsole(false) + .setRedirectErrorStream(true) + .start(); + } + + /** + * A sender can pass its generation check and then wait for the recorder's + * lock while a restart replaces the connector. The text must not be written + * at all: through the current connector it would leave the stale prompt on + * the replacement TUI's input line for its next submission, and the + * recorder it would reconcile belongs to the replacement too. + */ + @Test + void aSendAcceptedBeforeTheRestartDoesNotReachTheReplacementConnector() + throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + io.github.nbplugins.claudecodegui.process.PtyTtyConnector replacement = + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p); + long accepted = controller.simulateProcessGeneration(); + List targets = + new ArrayList<>(); + controller.simulatePtyWrite(bytes -> { }); + controller.simulatePtyWriteTargets(targets::add); + + // The tab is restarted: the accepted process is gone and a new PTY + // is already attached. + controller.stopProcess(); + controller.simulateConnector(replacement); + + controller.simulateGuiSendWithText("stale text", START_MS, accepted); + + assertTrue(targets.isEmpty(), + "the text of a replaced run must not be written"); + } finally { + p.destroyForcibly(); + } + } + + /** + * The Enter of a send whose process was replaced must not be written to + * the replacement TUI: it would submit whatever line that process has on + * its input line, and the evidence would describe a submission the + * accepted conversation never received. + */ + @Test + void aStaleEnterIsDroppedInsteadOfSubmittingTheReplacementProcess() + throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + io.github.nbplugins.claudecodegui.process.PtyTtyConnector replacement = + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p); + long accepted = controller.simulateProcessGeneration(); + List targets = + new ArrayList<>(); + controller.simulatePtyWrite(bytes -> { }); + controller.simulatePtyWriteTargets(targets::add); + + controller.simulateGuiSend("hello", START_MS); + controller.stopProcess(); + controller.simulateConnector(replacement); + + controller.completeGuiSend(() -> START_MS + 100L, accepted); + + assertTrue(targets.isEmpty(), + "no Enter may be written after the accepted process was replaced"); + } finally { + p.destroyForcibly(); + } + } + + /** + * A sender that blocked on the recorder's lock while a restart replaced the + * process must not reconcile its recorder either: the pending state and + * the cleared line would belong to the old send, and the replacement's own + * draft would be lost — a later panel send would then compose only its own + * text and the identity lookup could follow an unrelated file. + */ + @Test + void aStaleSendDoesNotRewriteTheReplacementRecordersDraft() throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + io.github.nbplugins.claudecodegui.process.PtyTtyConnector replacement = + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p); + long accepted = controller.simulateProcessGeneration(); + controller.simulatePtyWrite(bytes -> { }); + controller.simulatePtyWriteTargets(t -> { }); + + // The tab is restarted and the replacement's user types a draft. + controller.stopProcess(); + controller.simulateConnector(replacement); + controller.simulateTerminalInput( + "fresh".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS); + + // The old sender only now runs its reconciliation. + controller.simulateGuiSendWithText("stale text", START_MS + 100L, accepted); + + assertEquals("fresh suffix", + controller.simulateGuiSend(" suffix", START_MS + 200L), + "the replacement's draft must survive the stale send"); + } finally { + p.destroyForcibly(); + } + } + + /** + * Text typed during the pause and then submitted by the terminal Enter is + * only a part of the line (the panel text is not in the recorder), so the + * text becomes unknown while the first submission's time is kept. + */ + @Test + void terminalTextSubmittedByTheGapEnterKeepsItsOwnTime() { + controller.simulateGuiSend("hello", START_MS); + + controller.simulateTerminalInput( + " world".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 50L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.completeGuiSend(); + + assertNull(controller.simulateLastPromptText(), + "the recorded text is only a part of the submitted line"); + assertEquals(START_MS + 100L, controller.simulatePromptSubmittedAtValue(), + "the terminal Enter's time is the submission time"); + } + + /** + * Once a terminal Enter has submitted the panel line during the pause, the + * next line typed in the terminal is recorded in full: its submission must + * keep its text instead of being reported as a part of the panel line, and + * the panel's delayed Enter must not clear that evidence either. + */ + @Test + void textSubmittedAfterTheGapEnterStaysKnown() { + controller.simulateGuiSend("hello", START_MS); + // The user submits the panel line from the terminal before the + // panel's own Enter is written. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 50L); + + assertEquals("next", controller.simulateTerminalInput( + "next\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the line typed on the fresh line is recorded in full"); + + controller.completeGuiSend(() -> START_MS + 200L); + + assertEquals("next", controller.simulateLastPromptText(), + "the panel's Enter must not clear the newer submission's text"); + assertEquals(START_MS + 100L, controller.simulatePromptSubmittedAtValue(), + "the newer submission keeps its own time"); + } + + /** Clearing the input with Ctrl+U discards a recalled prompt: the next text is known. */ + @Test + void clearingARecalledPromptMakesTheNextSubmissionKnown() { + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS); + controller.simulateTerminalInput(new byte[]{0x15}, START_MS + 1L); + + assertEquals("typed after clearing", controller.simulateTerminalInput( + "typed after clearing\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L)); + } + + /** A tab inside a bracketed paste is literal prompt text. */ + @Test + void aTabInsideABracketedPasteIsRecordedAsTyped() { + String paste = "\u001b[200~first\tsecond\u001b[201~\r"; + + assertEquals("first\tsecond", controller.simulateTerminalInput( + paste.getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS)); + } + + /** + * Live Tab makes the TUI complete the input from its own suggestions; the + * completed text never reaches this parser. The recorded line is therefore + * only a prefix of the submitted prompt, so the text must be treated as + * unknown instead of being matched against the JSONL. + */ + @Test + void aTerminalCompletedPromptHasUnknownText() { + controller.simulateTerminalInput( + "inspect @REA".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 1L); + + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 2L), + "the prefix typed before Tab is not the whole submitted prompt"); + } + + /** Ctrl+U after a completion discards it, so the next prompt is known again. */ + @Test + void clearingACompletedPromptMakesTheNextSubmissionKnown() { + controller.simulateTerminalInput( + "inspect @REA".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 1L); + controller.simulateTerminalInput(new byte[]{0x15}, START_MS + 2L); + + assertEquals("typed after clearing", controller.simulateTerminalInput( + "typed after clearing\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 3L)); + } + + /** + * While a choice menu is open, Tab acts on the menu ("Tab to amend") and + * Enter selects an item rather than submitting a prompt. Marking that Tab + * as a completion would record the selection as an unknown-text prompt and + * let the adoption pass take a sibling's newer turn. + */ + @Test + void aTabInAChoiceMenuDoesNotRecordAPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + model.setActiveChoiceMenu(new ChoiceMenuModel("Pick one", List.of( + new ChoiceMenuModel.Option("Yes", "1"), + new ChoiceMenuModel.Option("No", "2")), 0)); + // The sibling submits its own turn while the menu is open. + appendUserPrompt(dir, "shared", "sibling turn", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // Tab acts on the menu, Enter selects the item. + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 11_000L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 11_001L); + + assertEquals("own", controller.resolveActiveSessionId(), + "a menu Tab and its selection are not a submitted prompt"); + } finally { + model.clearChoiceMenu(); + tabB.stopProcess(); + } + } + + /** + * A digit typed while a numbered choice menu is open selects an item + * immediately and is consumed by the TUI, so it must not become draft text + * for the prompt typed after the menu is dismissed: the malformed text + * could no longer match the file the prompt was actually written to. + * + *

The menu is left in the model on purpose: the screen is polled only + * every half second, and a prompt submitted before the poll catches up is + * still a real submission, not a menu answer. + */ + @Test + void aMenuSelectionDigitIsNotRecordedAsPromptDraft() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Pick one", List.of( + new ChoiceMenuModel.Option("Yes", "1"), + new ChoiceMenuModel.Option("No", "2")), 0)); + + controller.simulateTerminalInput(new byte[]{'1'}, START_MS); + // The screen poll has not refreshed yet: the model still reports the + // menu the digit dismissed. + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 1L), + "the menu selection must not prefix the submitted prompt"); + } + + /** + * Up recalls the history while the prompt panel hides menus: the poll + * publishes a permission menu only after the key, and Enter then answers + * that menu. The recalled text was the menu's search filter, not a + * submitted prompt, so it must not replace the exact evidence of the + * prompt already submitted — losing it makes the running conversation + * unmatchable and the scan can adopt an unrelated file. + */ + @Test + void aRecalledLineDoesNotReplaceTheEvidenceWhenEnterAnswersAMenu() { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + + // Up arrives before the poll publishes the menu. + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 20_000L); + model.setActiveChoiceMenu(menu("Do you want to proceed?", "Yes", "No")); + + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 20_100L); + + assertEquals("hello", controller.simulateLastPromptText(), + "the menu answer must not lift the submitted prompt's evidence"); + assertEquals(START_MS + 10_000L, controller.simulatePromptSubmittedAtValue(), + "the menu answer is not a submission of the recalled line"); + } + + /** + * A numbered menu can offer a free-text option recognised only by its + * label ({@code "Type something."}): the digit activates that option's + * editor instead of answering the menu, so the menu stays open and the + * text typed next is the menu's answer, not a submitted prompt. + */ + @Test + void aDigitOnALabelTypedTextOptionDoesNotDismissTheMenu() { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + controller.simulateTerminalInput(new byte[]{'3'}, START_MS); + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the answer to a text menu is not a submitted prompt"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + } + + /** + * The digit's own option decides whether the menu closes: an ordinary + * choice in a menu that also offers a text-input option still selects and + * closes the TUI menu, so the prompt typed next must be recorded as a + * prompt rather than swallowed as another menu answer. + */ + @Test + void anOrdinaryChoiceInAMixedMenuStillClosesItOnItsDigit() { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + controller.simulateTerminalInput(new byte[]{'1'}, START_MS); + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "an ordinary choice closes the menu, so the next Enter submits a prompt"); + assertEquals("hello", controller.simulateLastPromptText(), + "the terminal prompt becomes the evidence"); + } + + /** + * A digit typed while the menu's text-input option already has the focus is + * text for that editor, not the selection of the ordinary option it + * numbers — the answer protocol navigates with arrows in that case (see + * {@code writePtyAnswer}). The menu stays open, so the rest of the answer + * must not be recorded as a submitted prompt. + */ + @Test + void aDigitTypedIntoTheFocusedTextOptionDoesNotDismissTheMenu() { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 2)); + + controller.simulateTerminalInput(new byte[]{'1'}, START_MS); + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the answer to a text menu is not a submitted prompt"); + assertNull(controller.simulateLastPromptText(), + "the digit went into the focused editor, so the answer is not prompt evidence"); + } + + /** + * A digit that activates a menu's text editor moves the cursor only in the + * TUI: the model still reports the previous focus until the next screen + * poll. Digits typed in that window are text for the editor, so they must + * not retire the menu even when they name an ordinary option. + */ + @Test + void aDigitTypedAfterActivatingTheEditorDoesNotDismissTheMenu() { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + // The digit activates the editor; the poll has not moved the focus. + controller.simulateTerminalInput(new byte[]{'3'}, START_MS); + controller.simulateTerminalInput(new byte[]{'1'}, START_MS + 50L); + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the digits went into the activated editor; the answer is not a prompt"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + } + + /** + * A digit that names no item selects nothing: the TUI keeps the menu open, + * so the recorder must not retire it — otherwise the text typed next is + * recorded as prompt evidence instead of staying a menu answer. + */ + @Test + void aDigitThatNamesNoMenuOptionDoesNotDismissTheMenu() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Pick one", List.of( + new ChoiceMenuModel.Option("Yes", "1"), + new ChoiceMenuModel.Option("No", "2")), 0)); + + controller.simulateTerminalInput(new byte[]{'9'}, START_MS); + // The next digit selects: the menu closes and its text is discarded. + controller.simulateTerminalInput(new byte[]{'1'}, START_MS + 50L); + + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "a digit naming no option must not retire the menu"); + assertEquals("hello", controller.simulateLastPromptText(), + "the terminal prompt becomes the evidence"); + } + + /** + * A checkbox menu's free-text option is answered with the text, Up, then + * Right, which submits the menu. The text belongs to the menu answer, not + * to the prompt typed after it, and the Right must retire the menu the + * model still reports until the next screen poll. + */ + @Test + void aFreeTextCheckboxAnswerDoesNotPrefixTheNextPrompt() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Pick one", List.of( + checkbox("1", false), + checkbox("2", false), + new ChoiceMenuModel.Option("Type something", "3", null, + false, false, true)), 0)); + + controller.simulateTerminalInput( + "other".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 1L); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'C'}, START_MS + 2L); + // The screen poll has not refreshed yet: the model still reports the + // menu the Right submitted. + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 3L), + "the checkbox answer must not prefix the submitted prompt"); + } + + /** + * Text typed into a picker's search box is not prompt draft. When a live + * Esc cancels the picker (the GUI cancel path already resets the recorder), + * the search text must be discarded too, or the next prompt is published + * with the stale prefix and can no longer match the conversation it was + * written to. + */ + @Test + void aPickerSearchCancelledLiveDoesNotPrefixTheNextPrompt() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume", List.of( + new ChoiceMenuModel.Option("One", "1"), + new ChoiceMenuModel.Option("Two", "2")), 0)); + + controller.simulateTerminalInput( + "work".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + // The user cancels the picker with Esc in the terminal. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 1L); + // The screen no longer shows the picker. + model.clearChoiceMenu(); + + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L), + "the cancelled picker search must not prefix the submitted prompt"); + } + + /** + * The unnumbered {@code /resume} picker uses {@code ARROW:} answers and + * "Type to search": a digit typed there is search text, not a selection, + * so it must not retire the menu. Retiring it would let the rest of the + * search text be recorded as prompt draft, and the picker's later Esc + * would no longer discard it. + */ + @Test + void aDigitTypedIntoASearchablePickerDoesNotRetireTheMenu() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + // Search text typed into the picker: its digits are search text. + controller.simulateTerminalInput( + "2026".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + // The user cancels the picker with Esc in the terminal. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 1L); + + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L), + "the cancelled picker search must not prefix the submitted prompt"); + } + + /** + * A standalone Esc that cancelled the picker is consumed by the next + * input. When that input is a GUI submission, the picker's search text must + * not be published as part of the line, since the TUI submits only the + * panel text. + */ + @Test + void aPickerSearchCancelledBeforeAGuiSendIsNotPublished() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume", List.of( + new ChoiceMenuModel.Option("One", "1"), + new ChoiceMenuModel.Option("Two", "2")), 0)); + + controller.simulateTerminalInput( + "work".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + // The user cancels the picker with Esc in the terminal; no terminal + // input follows before the prompt is sent from the panel. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 1L); + + assertEquals("hello", controller.simulateGuiSend("hello", START_MS + 2L), + "the cancelled picker search must not prefix the GUI prompt"); + } + + /** + * A launch-known tab owns only the conversation it launched on. Following a + * NEW sibling's provisional file must not make that file owned by this tab, + * or the sibling that created it loses it. + */ + @Test + void aLaunchKnownTabDoesNotOwnAnAdoptedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // The NEW sibling tab, which guessed the conversation b. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, false); + tabB.simulateLiveSession(config, null, Set.of("a", "b")); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // Both tabs submit the same text; the sibling's conversation holds + // it and this tab starts following that file. + controller.simulateSentPrompt("continue", START_MS + 10_000L); + tabB.simulateSentPrompt("continue", START_MS + 10_000L); + appendUserPrompt(dir, "b", "continue", START_MS + 10_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_500L); + assertEquals("b", controller.resolveActiveSessionId()); + + // The launch file's own record of the same text is flushed late, so + // it is written after the sibling's but stamped earlier: the + // sibling must keep its strictly newer conversation either way. + appendUserPrompt(dir, "a", "continue", START_MS + 10_200L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_300L); + + // The creator keeps the conversation it created: the adopted file + // must not be marked as owned by this tab. + assertEquals("b", tabB.resolveActiveSessionId(), + "the adopted file must stay the sibling's own"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A launch-known tab adopts a sibling's file whenever the sibling's prompt + * lands first — but that adoption is a guess, not an established claim. It + * must not hide the file from the scan of the tab whose process actually + * writes it: the creator would otherwise adopt a different conversation and + * later rename it instead of its own. + */ + @Test + void aFollowersClaimDoesNotHideTheFileFromItsCreator() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // The NEW sibling tab created conversation b, but has not scanned yet. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, false); + tabB.simulateLiveSession(config, null, Set.of("a", "b")); + + try { + // Both submitted the same text; the sibling's record lands first + // and this tab adopts the file provisionally before the sibling + // has had a chance to claim it. The prompt is recorded through the + // seams that do not queue a background scan, so the order in which + // the two tabs resolve their identity is fixed. + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + controller.simulatePromptText("continue"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("continue"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_500L); + assertEquals("b", controller.resolveActiveSessionId()); + + // The launch file's own record of the same text is flushed late, so + // it is written after the sibling's but stamped earlier. + appendUserPrompt(dir, "a", "continue", START_MS + 10_200L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_300L); + + // The creator still finds its own file despite the follower's + // claim, even though the launch file carries the same text. + assertEquals("b", tabB.resolveActiveSessionId(), + "the follower's guess must not hide the created file"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A resume command recalled from the terminal history is not recognised as + * one, but the Enter that answers the picker it opened is a conversation + * switch: the unclaimed destination must be followed for the next unknown + * prompt. + */ + @Test + void aRecalledResumeCommandFollowsTheUnclaimedDestination() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // Up recalls /resume from the history, Enter submits an unknown + // command, and the picker it opened is answered with an Enter. + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "1")), 0)); + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 9_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 9_100L)); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 9_500L)); + model.clearChoiceMenu(); + + // A recalled prompt: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 10_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_000L)); + + // The other launcher's own turn lands in the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, strictly later. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the picker selection must enable the unclaimed destination"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Confirming an ordinary menu (permission prompt, trust-folder check) + * answers a question in the current conversation; it does not switch the + * conversation. Treating it as a resume would let the switch recovery + * adopt an unrelated unheld record written by another tab that has just + * exited, instead of the launch conversation that holds the submission. + */ + @Test + void anOrdinaryMenuAnsweredInTheTerminalDoesNotEnableSwitchRecovery() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "c")); + + model.setActiveChoiceMenu(new ChoiceMenuModel("Security guide", List.of( + new ChoiceMenuModel.Option("No, exit", "ARROW:0"), + new ChoiceMenuModel.Option("Yes, I trust this folder", "ARROW:1")), 0)); + // The trust-folder confirmation is answered with Enter. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 8_000L); + model.clearChoiceMenu(); + + // A first prompt recalled from the history: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 9_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 9_100L)); + + // This tab's submission lands in the launch conversation... + appendUserPrompt(dir, "a", "recalled", START_MS + 9_700L); + setMtime(dir.resolve("a.jsonl"), START_MS + 9_800L); + // ...while another tab submits to c and exits, leaving a newer + // unheld record. + appendUserPrompt(dir, "c", "other work", START_MS + 9_900L); + setMtime(dir.resolve("c.jsonl"), START_MS + 10_000L); + + assertEquals("a", controller.resolveActiveSessionId(), + "a menu answer is not a conversation switch"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the launch conversation"); + } + + /** + * The same distinction for a menu answered from the plugin's choice panel: + * an {@code ARROW:} answer is a resume only when the menu open is the + * {@code /resume} session picker, not the trust-folder confirmation. + */ + @Test + void anOrdinaryMenuAnsweredInThePanelDoesNotEnableSwitchRecovery() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "c")); + + model.setActiveChoiceMenu(new ChoiceMenuModel("Security guide", List.of( + new ChoiceMenuModel.Option("No, exit", "ARROW:0"), + new ChoiceMenuModel.Option("Yes, I trust this folder", "ARROW:1")), 0)); + controller.simulateGuiChoiceAnswer("ARROW:1"); + model.clearChoiceMenu(); + + controller.simulatePromptSubmittedAt(START_MS + 9_100L); + controller.simulatePromptText(null); + + appendUserPrompt(dir, "a", "recalled", START_MS + 9_700L); + setMtime(dir.resolve("a.jsonl"), START_MS + 9_800L); + appendUserPrompt(dir, "c", "other work", START_MS + 9_900L); + setMtime(dir.resolve("c.jsonl"), START_MS + 10_000L); + + assertEquals("a", controller.resolveActiveSessionId(), + "an ARROW answer to an ordinary menu is not a resume"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the launch conversation"); + } + + /** + * A tab that resumed another live tab's conversation answers a permission + * menu with Enter after submitting its prompt. The menu answer changes + * nothing in the conversation, so it must not hide the submission from the + * identity scan: Save & Switch would otherwise rename the launch + * conversation while the resumed file is the one holding the prompt. + */ + @Test + void aPermissionMenuAnswerDoesNotHideTheSubmittedPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "x", "x"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("x.jsonl"), START_MS - 5_000L); + + // A sibling tab resumed x and is writing it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "x", Set.of("a", "x")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "x")); + + try { + controller.simulateGuiSend("hello", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_100L); + + // The submission went to the resumed conversation. + appendUserPrompt(dir, "x", "hello", START_MS + 10_500L); + setMtime(dir.resolve("x.jsonl"), START_MS + 10_600L); + + // A permission menu is answered with Enter before the next scan. + model.setActiveChoiceMenu(new ChoiceMenuModel("Allow edit?", List.of( + new ChoiceMenuModel.Option("Yes", "ARROW:0"), + new ChoiceMenuModel.Option("No", "ARROW:1")), 0)); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 10_700L); + model.clearChoiceMenu(); + + controller.stopAndRename("Resumed title"); + } finally { + tabB.stopProcess(); + } + + assertEquals("Resumed title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "x").customTitle(), + "the conversation holding the submitted prompt is renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation was not the one on screen"); + } + + /** + * A picker cancelled with a standalone Esc immediately before a GUI send + * stays dismissed: the send rewrites the recorder, but the screen poll has + * not removed the model's stale entry yet. Without the dismissed-menu + * marker surviving those resets, the next prompt typed in the terminal is + * swallowed as another menu answer and never becomes prompt evidence. + */ + @Test + void aPickerCancelledBeforeAGuiSendStaysDismissed() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + + controller.simulateTerminalInput( + "work".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + // A standalone Esc cancels the picker; the model still reports it + // until the next screen poll. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 1L); + + controller.simulateGuiSend("hello", START_MS + 2L); + controller.completeGuiSend(() -> START_MS + 200L); + + assertEquals("hello", controller.simulateLastPromptText(), + "the panel send is the submitted prompt"); + assertEquals("next", controller.simulateTerminalInput( + "next\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 300L), + "the cancelled picker must stay dismissed after a GUI send"); + assertEquals("next", controller.simulateLastPromptText(), + "the terminal prompt must become the evidence"); + } + + /** + * With the menu hidden the prompt panel stays usable while the + * {@code /resume} picker is open: the panel text goes into the picker's + * search field and the panel's Enter selects an entry instead of + * submitting a prompt. The filter must not become prompt evidence, and + * the picker it answered must not swallow the next terminal prompt before + * the screen poll removes the stale model entry. + */ + @Test + void aPanelAnswerToTheOpenPickerIsNotPromptEvidence() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + + controller.simulateGuiSend("work", START_MS); + controller.completeGuiSend(() -> START_MS + 200L); + + assertNull(controller.simulateLastPromptText(), + "the picker's search filter is not a prompt"); + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 300L), + "the answered picker must not swallow the next prompt"); + assertEquals("hello", controller.simulateLastPromptText(), + "the terminal prompt becomes the evidence"); + assertEquals(START_MS + 300L, controller.simulatePromptSubmittedAtValue(), + "the terminal prompt keeps its own submission time"); + } + + /** + * Cancelling the picker from the plugin's choice panel consumes it, but + * the model keeps reporting the entry until the next poll: a panel send + * in that window is a prompt, not another answer to the cancelled picker. + */ + @Test + void aPanelSendAfterThePanelCancelledTheMenuIsAPrompt() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + // Cancel sends Esc to the TUI; the model entry is not cleared here. + controller.writePtyAnswer(null); + + controller.simulateGuiSend("hello", START_MS); + controller.completeGuiSend(() -> START_MS + 200L); + + assertEquals("hello", controller.simulateLastPromptText(), + "the cancelled picker must not turn the panel send into a menu answer"); + } + + /** + * A terminal Enter can answer the open picker during the panel send's + * pause, and the user then starts the next prompt before the delayed GUI + * Enter. That Enter submits the new draft: it must be recorded as the + * prompt, not discarded with the search filter the menu consumed. + */ + @Test + void aDraftTypedAfterThePickerSelectionIsRecorded() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + + controller.simulateGuiSend("work", START_MS); + // The terminal Enter selects the picker entry during the pause. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 50L); + // The user starts the next prompt on the fresh line. + controller.simulateTerminalInput( + "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L); + + controller.completeGuiSend(() -> START_MS + 200L); + + assertEquals("hello", controller.simulateLastPromptText(), + "the delayed Enter submits the draft typed after the selection"); + assertEquals(START_MS + 200L, controller.simulatePromptSubmittedAtValue(), + "the draft keeps its own submission time"); + } + + /** + * A digit answers a numbered menu immediately and closes it, so the text + * typed into it is the menu's search filter, not a prompt draft. Leaving + * it in the recorder would prefix the next prompt typed before the screen + * poll and publish text the conversation never received, which an + * identity scan cannot match. + */ + @Test + void textTypedIntoANumberedMenuDoesNotPrefixTheNextPrompt() { + model.setActiveChoiceMenu(new ChoiceMenuModel("Do you want to proceed?", List.of( + new ChoiceMenuModel.Option("Yes", "1"), + new ChoiceMenuModel.Option("No", "2")), 0)); + + controller.simulateTerminalInput( + "other".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + // The digit selects an item; the model still reports the menu until + // the next screen poll. + controller.simulateTerminalInput( + "1".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 50L); + + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the menu's search text is not part of the next prompt"); + assertEquals("hello", controller.simulateLastPromptText(), + "the terminal prompt becomes the evidence"); + } + + /** + * A standalone Esc typed during the panel send's pause cancels the + * {@code /resume} picker; the delayed Enter then acts on the empty main + * prompt. It must not be classified as a picker selection, which would + * mark a conversation switch and let the switch recovery adopt an + * unrelated unheld conversation after a recalled prompt. + */ + @Test + void aPickerCancelledDuringThePauseDoesNotEnableSwitchRecovery() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "c")); + + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateGuiSend("work", START_MS + 8_000L); + // A standalone Esc cancels the picker during the pause; the model + // still reports it until the next screen poll. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 8_050L); + controller.completeGuiSend(() -> START_MS + 8_200L); + + // A prompt recalled from the history: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 9_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 9_100L)); + + // This tab's submission lands in the launch conversation... + appendUserPrompt(dir, "a", "recalled", START_MS + 9_700L); + setMtime(dir.resolve("a.jsonl"), START_MS + 9_800L); + // ...while an unrelated unheld conversation gets a newer record. + appendUserPrompt(dir, "c", "other work", START_MS + 9_900L); + setMtime(dir.resolve("c.jsonl"), START_MS + 10_000L); + + assertEquals("a", controller.resolveActiveSessionId(), + "a cancelled picker is not a conversation switch"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the launch conversation"); + } + + /** + * A panel send of a bare digit answers a numbered menu at the initial text + * write — the answer protocol sends such digits without Enter — so the menu + * is gone for the pause. The prompt typed then starts on the empty input + * line and must be published as exact evidence instead of being swallowed + * as one more menu answer. + */ + @Test + void aDigitSentFromThePanelRetiresTheMenuBeforeThePause() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Do you want to proceed?", List.of( + new ChoiceMenuModel.Option("Yes", "1"), + new ChoiceMenuModel.Option("No", "2")), 0)); + + controller.simulateGuiSendWithText("1", START_MS); + // The screen poll has not refreshed yet: the model still reports the + // menu the panel digit answered. + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 50L), + "the menu was answered at the write, so this Enter submits a prompt"); + assertEquals("hello", controller.simulateLastPromptText(), + "the prompt typed during the pause is exact evidence"); + + controller.completeGuiSend(() -> START_MS + 200L); + + assertEquals("hello", controller.simulateLastPromptText(), + "the delayed Enter acts on the empty line and keeps the evidence"); + assertEquals(START_MS + 50L, controller.simulatePromptSubmittedAtValue(), + "the prompt keeps its own submission time"); + } + + /** + * A draft typed after the panel's digit answered a numbered menu sits on + * the empty input line, and the delayed Enter submits it as a prompt: it + * must be recorded, not discarded with the menu answer. + */ + @Test + void aDraftTypedAfterThePanelAnsweredTheMenuIsRecorded() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Do you want to proceed?", List.of( + new ChoiceMenuModel.Option("Yes", "1"), + new ChoiceMenuModel.Option("No", "2")), 0)); + + controller.simulateGuiSendWithText("1", START_MS); + controller.simulateTerminalInput( + "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 50L); + + controller.completeGuiSend(() -> START_MS + 200L); + + assertEquals("hello", controller.simulateLastPromptText(), + "the delayed Enter submits the draft typed after the menu answer"); + assertEquals(START_MS + 200L, controller.simulatePromptSubmittedAtValue(), + "the draft keeps the submission time of the Enter that sent it"); + } + + /** + * Once a NEW run has resolved its own conversation, newer unrelated files + * must not push it out of the bounded prompt-evidence list: a file that + * merely carries the same common prompt text would then take over the + * tab's identity and Save & Switch would rename the wrong session. + */ + @Test + void theResolvedOwnSessionSurvivesTheCandidateCap() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("own")); + + // The run's first prompt creates its own conversation. + controller.simulatePromptSubmittedAt(START_MS + 1_000L); + controller.simulatePromptText("first"); + appendUserPrompt(dir, "own", "first", START_MS + 1_100L); + setMtime(dir.resolve("own.jsonl"), START_MS + 1_200L); + assertEquals("own", controller.resolveActiveSessionId()); + + // A second, common prompt: eight unrelated conversations are written + // after this tab's own file, and one of them happens to hold the same + // text. The own file drops out of the newest-eight discovery list but + // is still the conversation this run writes. + controller.simulatePromptSubmittedAt(START_MS + 2_000L); + controller.simulatePromptText("continue"); + appendUserPrompt(dir, "own", "continue", START_MS + 2_100L); + setMtime(dir.resolve("own.jsonl"), START_MS + 2_200L); + for (int i = 0; i < 7; i++) { + String id = "sibling" + i; + writeSession(dir, id, id); + appendUserPrompt(dir, id, "own turn " + i, START_MS + 2_300L + i); + setMtime(dir.resolve(id + ".jsonl"), START_MS + 2_400L + i); + } + writeSession(dir, "impostor", "impostor"); + appendUserPrompt(dir, "impostor", "continue", START_MS + 2_500L); + setMtime(dir.resolve("impostor.jsonl"), START_MS + 2_600L); + + assertEquals("own", controller.resolveActiveSessionId(), + "the known own conversation must not be dropped by the candidate cap"); + assertEquals("own", controller.resolveActiveSessionId(), + "later scans must keep the known own conversation"); + } + + /** + * Rounded file times can put the resolved own file just below the + * submission cutoff while an unrelated conversation records the same + * common text in the next timestamp bucket. The own file is stronger + * evidence: the mtime slack must be applied to it before the lookalike + * match is accepted and replaces its identity. + */ + @Test + void theKnownOwnSessionIsFoundThroughTheMtimeSlack() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("own")); + + controller.simulatePromptSubmittedAt(START_MS + 1_000L); + controller.simulatePromptText("first"); + appendUserPrompt(dir, "own", "first", START_MS + 1_100L); + setMtime(dir.resolve("own.jsonl"), START_MS + 1_200L); + assertEquals("own", controller.resolveActiveSessionId()); + + // The own file holds the second prompt, but its rounded mtime lands + // just below the cutoff; an unrelated file with the same text is + // written in the next timestamp bucket. + long since = START_MS + 5_000L; + controller.simulatePromptSubmittedAt(since); + controller.simulatePromptText("continue"); + appendUserPrompt(dir, "own", "continue", since + 100L); + setMtime(dir.resolve("own.jsonl"), since - 500L); + writeSession(dir, "lookalike", "lookalike"); + appendUserPrompt(dir, "lookalike", "continue", since + 200L); + setMtime(dir.resolve("lookalike.jsonl"), since + 300L); + + assertEquals("own", controller.resolveActiveSessionId(), + "the own conversation is found through the mtime slack"); + assertEquals("own", controller.resolveActiveSessionId(), + "later scans must keep the own conversation"); + } + @Test + void aBorrowedActiveConversationIsNotPreferred() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // The NEW sibling tab, which guessed the conversation b. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, false); + tabB.simulateLiveSession(config, null, Set.of("a", "b")); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // Both tabs submit the same text; only the sibling's file holds it + // yet, so this tab temporarily follows it. The prompt is recorded + // through the seams that do not queue a background scan, so the + // tab that has not claimed the file yet cannot do so concurrently: + // the launch conversation must win on the evidence alone. + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + controller.simulatePromptText("continue"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_500L); + assertEquals("b", controller.resolveActiveSessionId()); + + // This tab's own launch file records the prompt strictly later. + appendUserPrompt(dir, "a", "continue", START_MS + 10_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_700L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the borrowed file must not outrank this tab's own prompt"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the launched conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The same borrowed file, claimed by the sibling after this tab + * provisionally adopted it: the claim registry never marks this tab as a + * borrower then, but the file is still the sibling's guess, so it must not + * outrank the launch conversation holding this tab's prompt. + */ + @Test + void aFileTheSiblingClaimsLaterDoesNotOutrankTheLaunchConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, false); + tabB.simulateLiveSession(config, null, Set.of("a", "b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // Only the sibling's file holds the text yet, and nobody has + // claimed it: this tab adopts it provisionally. + controller.simulateSentPrompt("continue", START_MS + 10_000L); + appendUserPrompt(dir, "b", "continue", START_MS + 10_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_500L); + assertEquals("b", controller.resolveActiveSessionId()); + + // The sibling publishes its claim on b afterwards. + tabB.simulateClaimSession(config, "b"); + + appendUserPrompt(dir, "a", "continue", START_MS + 10_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_700L); + + assertEquals("a", controller.resolveActiveSessionId(), + "a file another live tab holds is not this tab's own evidence"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A /resume command assembled across a terminal draft and the panel text + * switches the conversation like a plain one: the unknown prompt that + * follows must be recovered from the unclaimed destination. + */ + @Test + void aComposedResumeCommandFollowsTheUnclaimedDestination() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The command is split: the draft was typed in the terminal and the + // rest is sent from the panel. The picker it opens is not answered + // through the terminal recorder. + controller.simulateTerminalInput( + "/res".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 9_000L); + assertEquals("/resume", + controller.simulateGuiSend("ume", START_MS + 9_100L)); + controller.completeGuiSend(() -> START_MS + 9_200L); + + // A recalled prompt: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 10_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_000L)); + + // The other launcher's own turn lands in the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, strictly later. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the composed command must enable the unclaimed destination"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Once a switch recovery has resolved the unheld conversation the run + * resumed into, a later unknown-text submission keeps it: without the + * resolved own ID the own-candidate pass is skipped, and a live sibling's + * newer human record would make Save & Switch rename the sibling's + * conversation. + */ + @Test + void theSwitchRecoveryKeepsItsOwnFileForLaterUnknownPrompts() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + // A sibling tab runs its own conversation c. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "b", "c")); + + try { + // The run switches to the unheld conversation b and submits a + // recalled prompt there: its text is unknown. + controller.simulateTerminalInput( + "/res".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 9_000L); + assertEquals("/resume", + controller.simulateGuiSend("ume", START_MS + 9_100L)); + controller.completeGuiSend(() -> START_MS + 9_200L); + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 10_000L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 11_000L); + appendUserPrompt(dir, "b", "first", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + assertEquals("b", controller.resolveActiveSessionId(), + "the switch must resolve the unheld destination"); + + // A second recalled prompt goes to b, while the sibling appends a + // newer human record to its own live conversation c. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 13_000L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 14_000L); + appendUserPrompt(dir, "b", "second", START_MS + 14_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 14_500L); + appendUserPrompt(dir, "c", "sibling", START_MS + 14_600L); + setMtime(dir.resolve("c.jsonl"), START_MS + 14_700L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the resolved switch destination must stay this tab's own"); + } finally { + tabC.stopProcess(); + } + } + + /** + * A terminal Enter during the GUI send's pause submits the panel text + * together with the keys typed there. The text the terminal parser + * recorded is only a part of that prompt, so an identity scan in the + * window before the panel's Enter must not adopt another conversation's + * file matched by the part alone. + */ + @Test + void aTerminalEnterDuringTheGuiSendPublishesNoPartialPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "b", "b"); + writeSession(dir, "w", "w"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("w.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "b", Set.of("b", "w")); + + // The panel text is written, and during the pause before its Enter the + // user types in the terminal and presses Enter: the TUI submits the + // composed line "hello world". + controller.simulateGuiSend("hello", START_MS + 10_000L); + controller.simulateTerminalInput( + " world".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_050L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 10_100L); + + // An unrelated conversation holds the partial text; the launched one + // holds the composed prompt, slightly later. + appendUserPrompt(dir, "w", "world", START_MS + 10_200L); + setMtime(dir.resolve("w.jsonl"), START_MS + 10_300L); + appendUserPrompt(dir, "b", "hello world", START_MS + 10_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_500L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the partial terminal text must not identify a conversation"); + + controller.completeGuiSend(); + assertEquals("b", controller.resolveActiveSessionId(), + "the composed prompt must keep the launched conversation"); + } + + /** + * An Enter on the still-empty terminal line does nothing on the TUI, so it + * must not invalidate the prompt evidence: the submitted prompt would stop + * matching, and the resumed conversation's live claim stays excluded from + * the file-time fallbacks, leaving Save & Switch on the previous + * conversation. + */ + @Test + void anEmptyTerminalEnterKeepsTheSubmittedPromptEvidence() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A live sibling holds the conversation resumed in the TUI. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("hello", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + // An Enter on the empty line before the scan. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 11_500L); + + // The submission lands in the resumed conversation. + appendUserPrompt(dir, "b", "hello", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_700L); + + assertEquals("b", controller.resolveActiveSessionId(), + "an empty Enter must not hide the submitted prompt"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A draft typed in the terminal is consumed by a prompt sent from the GUI: + * its characters are part of that submission, so the next line typed in + * the terminal must not carry them, and the submission's evidence is the + * whole line the TUI receives. + */ + @Test + void aGuiSendConsumesTheTerminalDraftBeforeTheNextLine() { + controller.simulateTerminalInput( + "draft".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + assertEquals("drafthello", controller.simulateGuiSend("hello", START_MS + 1_000L), + "the GUI submission takes the terminal draft with it"); + controller.completeGuiSend(); + assertEquals("next", controller.simulateTerminalInput( + "next\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2_000L), + "the consumed draft must not prefix the next terminal line"); + } + + /** + * The cancellation of a picker must be consumed before the next escape + * sequence: a bracketed paste starts with its own {@code ESC} in a separate + * write, and starting that sequence must not forget that the previous Esc + * cancelled the picker, or the stale search text prefixes the pasted + * prompt. + */ + @Test + void aPickerCancellationIsConsumedBeforeTheNextEscapeSequence() { + java.nio.charset.Charset utf8 = java.nio.charset.StandardCharsets.UTF_8; + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume", List.of( + new ChoiceMenuModel.Option("One", "1"), + new ChoiceMenuModel.Option("Two", "2")), 0)); + + controller.simulateTerminalInput("work".getBytes(utf8), START_MS); + // The user cancels the picker with Esc in the terminal. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 1L); + // The screen no longer shows the picker. + model.clearChoiceMenu(); + + // A bracketed paste begins with its own escape sequence. + controller.simulateTerminalInput("\u001b[200~".getBytes(utf8), START_MS + 2L); + controller.simulateTerminalInput("hello".getBytes(utf8), START_MS + 3L); + controller.simulateTerminalInput("\u001b[201~".getBytes(utf8), START_MS + 4L); + + assertEquals("hello", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 5L), + "the cancelled picker search must not prefix the pasted prompt"); + } + + /** + * A whitespace-only GUI send carries no prompt text of this tab, so it must + * not replace the evidence of the previous prompt: otherwise the scan looks + * for an unknown-text submission, and a sibling that appends a real prompt + * afterwards is adopted even though this tab's own conversation is strictly + * newer. + */ + @Test + void aWhitespaceOnlySendKeepsThePreviousPromptEvidence() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a and wrote a prompt there. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + appendUserPrompt(dir, "a", "first prompt", START_MS + 5_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 5_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation holds this tab's prompt"); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // The GUI sends a lone Enter: no prompt text of this tab is written. + controller.simulateSentPrompt(" ", START_MS + 10_000L); + // This tab's turn only appends assistant output, which is newer. + java.nio.file.Files.writeString(dir.resolve("a.jsonl"), + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\"," + + "\"content\":\"assistant turn\"},\"timestamp\":\"" + + java.time.Instant.ofEpochMilli(START_MS + 10_500L) + "\"}\n", + java.nio.file.StandardOpenOption.APPEND); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_000L); + // The sibling submits a real prompt afterwards. + appendUserPrompt(dir, "b", "hello", START_MS + 12_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_500L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the sibling's prompt must not replace the previous evidence"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the launch conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Pasting before a large draft must insert the pasted text in one + * operation. Inserting character by character copies the whole unchanged + * suffix for every character, which took seconds for a half-megabyte + * draft and blocked terminal writes on the calling thread. + */ + @Test + void aLargePasteBeforeALargeDraftIsInsertedQuickly() { + String draft = "d".repeat(512 * 1024); + String pasted = "p".repeat(512 * 1024); + controller.simulateTerminalInput( + draft.getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + // Home, then a bracketed paste at the beginning of the draft. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'H'}, START_MS + 1L); + + long started = System.nanoTime(); + assertNull(controller.simulateTerminalInput( + ("\u001b[200~" + pasted + "\u001b[201~") + .getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L)); + long elapsedMillis = (System.nanoTime() - started) / 1_000_000; + + assertEquals(pasted.length() + draft.length(), controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 3L).length(), + "the whole pasted text must precede the draft"); + assertTrue(elapsedMillis < 3_000, + "a paste must not rescan the draft per character (took " + + elapsedMillis + " ms)"); + } + + /** + * A paste with many line breaks must be inserted in the same single + * operation as its text. Handling every newline on its own copies the + * unchanged draft once per line, which turns a line-heavy paste into + * seconds of quadratic work on the thread feeding the terminal. + */ + @Test + void aNewlineHeavyPasteBeforeALargeDraftIsInsertedQuickly() { + String draft = "d".repeat(512 * 1024); + String pasted = "x\n".repeat(512 * 1024); + controller.simulateTerminalInput( + draft.getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + // Home, then a bracketed paste at the beginning of the draft. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'H'}, START_MS + 1L); + + long started = System.nanoTime(); + assertNull(controller.simulateTerminalInput( + ("\u001b[200~" + pasted + "\u001b[201~") + .getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L)); + long elapsedMillis = (System.nanoTime() - started) / 1_000_000; + + assertEquals(pasted.length() + draft.length(), controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 3L).length(), + "the whole pasted text, line breaks included, must precede the draft"); + assertTrue(elapsedMillis < 3_000, + "a paste must not rescan the draft per line (took " + + elapsedMillis + " ms)"); + } + + /** + * While a choice menu is open, Up/Down move the menu cursor and Enter + * selects the item; the typed line stays empty just as it does for a prompt + * recalled from the TUI history, but no prompt is submitted. Recording it + * as a recall would let the mtime-only fallback adopt a sibling's newest + * file even though the conversation on screen never changed. + */ + @Test + void arrowKeysInAChoiceMenuDoNotRecallThePromptHistory() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + model.setActiveChoiceMenu(new ChoiceMenuModel("Pick one", List.of( + new ChoiceMenuModel.Option("Yes", "1"), + new ChoiceMenuModel.Option("No", "2")), 0)); + // The sibling writes its own turn while the menu is open. + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // Up moves the menu cursor; Enter selects the item. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L); + + assertEquals("own", controller.resolveActiveSessionId(), + "menu navigation is not a prompt history recall"); + } finally { + model.clearChoiceMenu(); + tabB.stopProcess(); + } + } + + /** + * After joining another tab's conversation and submitting a prompt there, + * the identity scan may only run after the user started typing the next + * draft. A draft edit must not invalidate the submitted prompt's evidence, + * or the conversation on screen is renamed in place of this tab's old one. + */ + @Test + void draftTypingDoesNotInvalidateTheSubmittedPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user joins B's conversation in the TUI and submits a prompt. + appendUserPrompt(dir, "b", "joined b", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + controller.simulateTerminalInput( + "joined b\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + + // ... then starts typing the next prompt without submitting it. + controller.simulateTerminalInput( + "draft".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_500L); + + assertEquals("b", controller.resolveActiveSessionId(), + "typing a draft must not discard the submitted prompt's evidence"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A NEW tab and a live tab can submit the same text. The sibling's record + * may land while the NEW tab's own file is still being created, so the tab + * briefly adopts the sibling; its own file must win the identity back as + * soon as it appears instead of the borrowed one pinning itself forever. + */ + @Test + void newTabWithTheSamePromptAsASibling_takesItsOwnFileWhenItAppears() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "sibling", "sibling"); + setMtime(dir.resolve("sibling.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "sibling", Set.of("sibling")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("sibling")); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + // The sibling submits the same text and its record lands after this + // tab's prompt, while this tab's own file does not exist yet. + appendUserPrompt(dir, "sibling", "hello", START_MS + 10_500L); + setMtime(dir.resolve("sibling.jsonl"), START_MS + 10_600L); + assertEquals("sibling", controller.resolveActiveSessionId(), + "with nothing of its own yet, the tab can only follow the shared prompt"); + + // This tab's own conversation appears, holding the same prompt. + writeSession(dir, "own", "own"); + appendUserPrompt(dir, "own", "hello", START_MS + 11_000L); + setMtime(dir.resolve("own.jsonl"), START_MS + 11_200L); + + assertEquals("own", controller.resolveActiveSessionId(), + "the tab's own conversation must win over a sibling with the same prompt"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The counterpart of the test above. While the NEW tab's own file is still + * pending it borrows the live tab's conversation. That provisional claim + * must not make the conversation look foreign to the tab that established + * it: otherwise, when the NEW tab's file appears, the sibling treats it as + * its own (it holds the same prompt) and the two tabs keep naming each + * other's conversations in Save & Switch. + */ + @Test + void borrowedClaimDoesNotStealTheOwnersConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "sibling", "sibling"); + setMtime(dir.resolve("sibling.jsonl"), START_MS - 5_000L); + + // Tab B established the conversation and has its own prompt evidence. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "sibling", Set.of("sibling")); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 9_000L); + + // Tab A is NEW and submits the same text. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("sibling")); + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + + try { + appendUserPrompt(dir, "sibling", "hello", START_MS + 10_500L); + setMtime(dir.resolve("sibling.jsonl"), START_MS + 10_600L); + assertEquals("sibling", controller.resolveActiveSessionId(), + "with nothing of its own yet, the NEW tab borrows the sibling"); + + // A's own conversation appears, holding the same prompt. B scans + // before A has claimed the new file. + writeSession(dir, "own", "own"); + appendUserPrompt(dir, "own", "hello", START_MS + 11_000L); + setMtime(dir.resolve("own.jsonl"), START_MS + 11_200L); + + assertEquals("sibling", tabB.resolveActiveSessionId(), + "a borrowed claim must not make the owner adopt the borrower's file"); + + assertEquals("own", controller.resolveActiveSessionId(), + "the NEW tab must still return to its own conversation"); + assertEquals("sibling", tabB.resolveActiveSessionId(), + "both tabs must keep their own conversations once both files exist"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Two NEW tabs that have not resolved their session yet submit the same + * text. Neither knows which file is its own: the first file to appear can + * only be guessed, so the claim stays provisional instead of establishing + * ownership over a sibling's conversation. When a second file with the same + * prompt appears, the evidence cannot tell the two tabs apart — displacing + * the current claim would swap the two conversations — so both keep the + * conversation they already track. + */ + @Test + void twoUnresolvedNewTabsDoNotSwapTheConversationTheyShare() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + java.nio.charset.Charset utf8 = java.nio.charset.StandardCharsets.UTF_8; + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + // B's conversation is written first; A scans before B and can only + // follow the only file that holds the prompt. + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "hello", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_600L); + assertEquals("b", controller.resolveActiveSessionId()); + assertEquals("b", tabB.resolveActiveSessionId()); + + // A's own conversation appears with the same prompt. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_200L); + + assertEquals("b", tabB.resolveActiveSessionId(), + "a shared provisional claim must not be displaced"); + assertEquals("b", controller.resolveActiveSessionId(), + "the tab that guessed first must not be swapped off its file"); + + assertEquals("b", controller.resolveActiveSessionId(), + "both tabs must keep the conversation they track on later scans"); + assertEquals("b", tabB.resolveActiveSessionId()); + } finally { + tabB.stopProcess(); + } + } + + /** + * The same two unresolved tabs stopped with a name: neither file can be + * proven to be this tab's, so the name would land in the sibling's + * conversation. The rename must be skipped rather than guessed. + */ + @Test + void aStopDoesNotRenameEitherOfTwoSharedUnresolvedFiles() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "hello", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_600L); + assertEquals("b", controller.resolveActiveSessionId()); + assertEquals("b", tabB.resolveActiveSessionId()); + + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_200L); + assertEquals("b", controller.resolveActiveSessionId(), + "the shared conversation is still the one this tab tracks"); + + controller.stopAndRename("A-title"); + } finally { + tabB.stopProcess(); + } + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the sibling's conversation must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the ambiguous candidate must not be renamed either"); + } + + /** + * The sibling of {@link #aStopDoesNotRenameEitherOfTwoSharedUnresolvedFiles} + * exits without a rename before this tab is stopped. Its departure releases + * its claim but does not turn this tab's earlier guess into ownership: both + * files still hold the same prompt, so neither may be renamed. + */ + @Test + void aStopDoesNotRenameWhenTheSiblingExitedBeforeTheStop() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "hello", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_600L); + assertEquals("b", controller.resolveActiveSessionId()); + assertEquals("b", tabB.resolveActiveSessionId()); + + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_200L); + assertEquals("b", controller.resolveActiveSessionId(), + "the shared conversation is still the one this tab tracks"); + + // The sibling stops without renaming; its claim is released. + tabB.stopProcess(); + assertEquals("b", controller.resolveActiveSessionId(), + "the departure does not resolve the identity"); + + controller.stopAndRename("A-title"); + } finally { + tabB.stopProcess(); + } + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the sibling's conversation must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the ambiguous candidate must not be renamed either"); + } + + /** + * The tab was launched on {@code a} and the user then picked another live + * tab's {@code b} in the {@code /resume} picker. Nothing submitted since + * shows which file the process writes: {@code b} is claimed by its own tab, + * so no evidence pass can adopt it, and the tracked identity stays on the + * launch conversation. Treating that stale identity as this tab's own would + * write the name into {@code a} — the conversation the picker left behind. + */ + @Test + void anUnresolvedPickerSwitchDoesNotRenameTheLaunchConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A live sibling holds the conversation picked in the TUI. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + + // The /resume picker is published and the user confirms an entry: + // the switch is recorded, but which file was picked is not. + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume session", List.of( + new ChoiceMenuModel.Option("older", "ARROW:0"), + new ChoiceMenuModel.Option("newer", "ARROW:1")), 0)); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 20_000L); + + // The picked conversation receives only assistant output, so no + // prompt-based pass can resolve the switch either. + appendAssistantLine(dir, "b", START_MS + 21_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 21_100L); + + controller.stopAndRename("A-title"); + } finally { + tabB.stopProcess(); + } + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation the picker left behind must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the picked conversation cannot be proven to be this tab's"); + } + + /** + * A NEW tab resolved {@code a} by the prompt it submitted. Selecting a + * live sibling's {@code b} in {@code /resume} afterwards leaves the prompt + * evidence stale, and nothing shows which file the process writes: {@code a} + * is still tracked, though the switch left it behind. The resolved + * identity is no safer to rename than a launch one. + */ + @Test + void anUnresolvedPickerSwitchAlsoProtectsAResolvedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_600L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // A NEW tab: no conversation was known at launch. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + assertEquals("a", controller.resolveActiveSessionId(), + "the prompt resolves the conversation the NEW tab writes"); + + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume session", List.of( + new ChoiceMenuModel.Option("older", "ARROW:0"), + new ChoiceMenuModel.Option("newer", "ARROW:1")), 0)); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 20_000L); + + // The picked conversation receives only assistant output, so no + // prompt-based pass can resolve the switch. + appendAssistantLine(dir, "b", START_MS + 21_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 21_100L); + + controller.stopAndRename("A-title"); + } finally { + tabB.stopProcess(); + } + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation the picker left behind must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the picked conversation cannot be proven to be this tab's"); + } + + /** + * Both NEW tabs submit identical prompts, and both files hold the prompt + * before either tab scans its store. The first tab takes the newest file; + * the second excludes it as the first tab's claim and takes the other. The + * claim the second tab excluded is only the first tab's guess, so the two + * files stay indistinguishable for a rename: neither may be renamed. + */ + @Test + void aStopDoesNotRenameWhenTheSiblingHoldsTheOtherGuess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + // Both files exist before either tab scans. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_600L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "hello", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_200L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the first tab takes the newest file"); + assertEquals("a", tabB.resolveActiveSessionId(), + "the second tab can only take the other one"); + + tabB.stopAndRename("B-title"); + } finally { + tabB.stopProcess(); + } + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the guess this tab tracked must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the sibling's guess must not be renamed either"); + } + + /** + * A NEW tab that created its conversation and is merely followed by a + * sibling has nothing ambiguous about its file: the rename must still + * happen there. + */ + @Test + void aStopRenamesTheFileAFollowerOnlyFollows() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + // Only this tab's conversation holds the prompt; the sibling + // follows it because it has no file of its own. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_600L); + assertEquals("a", controller.resolveActiveSessionId()); + assertEquals("a", tabB.resolveActiveSessionId()); + + controller.stopAndRename("Shared Title"); + } finally { + tabB.stopProcess(); + } + + assertEquals("Shared Title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the creator's conversation is renamed although a sibling follows it"); + } + + /** + * The same two unresolved tabs with the scan order reversed: the tab that + * resolved the shared file first keeps it, and the tab that joined later + * must not cost it that conversation. A borrowed claim is not ownership. + */ + @Test + void aSiblingBorrowingAProvisionalClaimDoesNotDisplaceIt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + // The sibling resolves the shared conversation first; this tab joins. + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "hello", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_600L); + assertEquals("b", tabB.resolveActiveSessionId()); + assertEquals("b", controller.resolveActiveSessionId()); + + // A second file with the same prompt appears. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_200L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the provisional claim of the sibling must not be displaced"); + assertEquals("b", tabB.resolveActiveSessionId(), + "the tab that resolved the file first must keep it"); + + assertEquals("b", tabB.resolveActiveSessionId(), + "later scans must not swap the conversations either"); + assertEquals("b", controller.resolveActiveSessionId()); + } finally { + tabB.stopProcess(); + } + } + + /** + * A provisional file of another NEW tab can be borrowed and even match the + * own-prompt evidence, but it is not this tab's conversation: once the file + * this tab created holds the same prompt and is strictly newer, the + * borrowed hold must not stay preferred. Both submissions came from the + * GUI, so neither terminal input nor a GUI switch marks the change. + */ + @Test + void aBorrowedProvisionalConversationDoesNotStayPreferred() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of("a", "b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + try { + // Both tabs submit the same text from the GUI. + controller.simulateSentPrompt("hello", START_MS + 10_000L); + tabB.simulateSentPrompt("hello", START_MS + 10_000L); + // Let the background scans finish before any file exists: their + // timing is otherwise free to adopt a file ahead of the explicit + // resolutions this test describes. + controller.simulateAwaitIdentityScan(); + tabB.simulateAwaitIdentityScan(); + + // This tab resolves its own conversation first. + appendUserPrompt(dir, "a", "hello", START_MS + 10_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_500L); + assertEquals("a", controller.resolveActiveSessionId()); + + // The sibling's conversation appears later and the sibling + // resolves it. + appendUserPrompt(dir, "b", "hello", START_MS + 10_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_700L); + assertEquals("b", tabB.resolveActiveSessionId()); + + // The next identical submission is found only in the sibling's + // file: this tab temporarily borrows it. + controller.simulateSentPrompt("again", START_MS + 11_000L); + tabB.simulateSentPrompt("again", START_MS + 11_000L); + controller.simulateAwaitIdentityScan(); + tabB.simulateAwaitIdentityScan(); + appendUserPrompt(dir, "b", "again", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "with nothing of its own written yet, the tab borrows the sibling"); + + // This tab's own conversation is flushed later, strictly newer. + appendUserPrompt(dir, "a", "again", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "a borrowed provisional file must not stay preferred"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the tab's own conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A launch-known tab switched to an unclaimed conversation and resolved it; + * a later recalled submission temporarily follows a live sibling's file + * before its own conversation is flushed. The retained own ID must stay + * eligible, or the sibling's file is renamed instead. + */ + @Test + void aLaunchKnownTabRecoversItsSwitchedConversationAfterARecall() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "b", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + try { + // The tab switches to the unclaimed conversation b with a literal + // prompt. + controller.simulateSentPrompt("continue", START_MS + 10_000L); + appendUserPrompt(dir, "b", "continue", START_MS + 10_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_500L); + assertEquals("b", controller.resolveActiveSessionId()); + + // A recalled prompt: its text is unknown. + controller.simulatePromptSubmittedAt(START_MS + 11_000L); + controller.simulatePromptText(null); + + // The live sibling finishes its own turn first, so the tab + // temporarily follows it. + appendUserPrompt(dir, "c", "sibling", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + assertEquals("c", controller.resolveActiveSessionId(), + "the sibling record is adopted while the tab's file is not flushed"); + + // The tab's own conversation gets the actual submission, strictly + // newest. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the retained own conversation must be eligible again"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep it"); + } finally { + tabC.stopProcess(); + } + } + + /** + * A tab recalls a prompt in its own terminal while a live sibling finishes + * its own turn. The sibling's file is newer, but nothing indicates that the + * conversation on screen changed: a newer mtime alone must not steal the + * sibling's session (its turn appends assistant output, not a user message). + */ + @Test + void aHistoryRecallDoesNotAdoptASiblingConcurrentTurn() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + // Up recalls a prompt from this tab's own history; Enter submits it. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L); + + // The sibling appends its own assistant turn, which is newer. + java.nio.file.Files.writeString(dir.resolve("shared.jsonl"), + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\"," + + "\"content\":\"sibling turn\"},\"timestamp\":\"" + + java.time.Instant.ofEpochMilli(START_MS + 11_500L) + "\"}\n", + java.nio.file.StandardOpenOption.APPEND); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + assertEquals("own", controller.resolveActiveSessionId(), + "a newer file written by another tab is not evidence of a switch"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A /resume picker search text typed in the terminal belongs to the picker. + * When the GUI selects the destination, the recorded line must not carry + * that text into the next prompt. + */ + @Test + void pickerSearchTextDoesNotBecomePartOfTheNextPrompt() { + controller.simulateTerminalInput( + "work".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.writePtyAnswer("1"); + + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L)); + } + + /** + * A NEW tab may guess (and provisionally claim) a conversation, but an + * explicit {@code --resume} of that same file knows its identity at launch. + * The explicit resume must own the file, so the guessing tab stops treating + * it as its own and follows its own conversation once that appears. + */ + @Test + void anExplicitResumeOwnsTheFileEvenWhenANewTabGuessedItFirst() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "hello", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_600L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + assertEquals("b", controller.resolveActiveSessionId(), + "the NEW tab follows the conversation that holds its prompt"); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 11_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("b")); + + try { + // This tab's own conversation appears with the same prompt. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + + assertEquals("a", controller.resolveActiveSessionId(), + "an explicit resume owns the file; the guess must follow its own"); + assertEquals("b", tabB.resolveActiveSessionId(), + "the tab that resumed the conversation explicitly keeps it"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Two tabs resume the same saved conversation, so the second one legitimately + * borrows the file the first owns. A launch-known identity is that tab's own + * conversation even when another live tab shares it: an unrelated, exited + * conversation that recorded the same text after the submission must not be + * adopted in its place. + */ + @Test + void aSharedLaunchIdBeatsAnUnrelatedConversationWithTheSamePrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + writeSession(dir, "third", "third"); + setMtime(dir.resolve("third.jsonl"), START_MS - 5_000L); + + // This tab resumes the saved conversation and therefore owns it. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "shared", Set.of("shared", "third")); + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + + // A second tab resumes the same conversation and only borrows the file. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("shared", "third")); + tabB.simulatePromptText("continue"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + try { + appendUserPrompt(dir, "shared", "continue", START_MS + 10_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 10_600L); + + // An exited conversation records the same text after the submission. + appendUserPrompt(dir, "third", "continue", START_MS + 11_000L); + setMtime(dir.resolve("third.jsonl"), START_MS + 11_200L); + + assertEquals("shared", tabB.resolveActiveSessionId(), + "a shared launch identity must not yield to an unrelated newer file"); + assertEquals("shared", tabB.resolveActiveSessionId(), + "later scans must keep resolving the shared conversation"); + assertEquals("shared", controller.resolveActiveSessionId(), + "the owning tab keeps the conversation it launched"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Up recalls a draft that is never submitted before the tab restarts. The + * replacement process starts with empty input, so the first typed prompt is + * known in full; a leftover recall flag would record it as unknown text and + * let an unrelated newer conversation be adopted. + */ + @Test + void aRestartClearsAPromptRecalledButNeverSubmitted() { + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS); + controller.stopProcess(); + controller.simulateProcessStart(START_MS + 1_000L, true); + + assertEquals("typed prompt", controller.simulateTerminalInput( + "typed prompt\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2_000L), + "a recall from the previous run must not make the new prompt unknown"); + } + + /** + * A standalone Esc arrives as its own write and only dismisses the + * autocomplete: it must not consume the keystroke that follows, or the next + * prompt is recorded without that character and its evidence no longer + * matches the conversation on screen. + */ + @Test + void aStandaloneEscapeDoesNotSwallowTheNextKeystroke() { + byte[] esc = {0x1b}; + java.nio.charset.Charset utf8 = java.nio.charset.StandardCharsets.UTF_8; + + controller.simulateTerminalInput(esc, START_MS); + controller.simulateTerminalInput("hello".getBytes(utf8), START_MS + 1); + assertEquals("hello", controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 2), + "Esc must not consume the first character typed after it"); + + controller.simulateTerminalInput("world".getBytes(utf8), START_MS + 3); + controller.simulateTerminalInput(esc, START_MS + 4); + assertEquals("world", controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 5), + "Esc before Enter must not swallow the submission"); + } + + /** + * A prompt typed after a standalone Esc may itself start with {@code O} or + * {@code [}. Those characters belong to the input: a real SS3/CSI sequence + * is written in one piece, so treating the later write as its continuation + * drops characters and leaves prompt evidence that matches no session. + */ + @Test + void aStandaloneEscapeFollowedByASequencePrefixIsTypedText() { + byte[] esc = {0x1b}; + java.nio.charset.Charset utf8 = java.nio.charset.StandardCharsets.UTF_8; + + controller.simulateTerminalInput(esc, START_MS); + assertEquals("Open file", controller.simulateTerminalInput( + "Open file\r".getBytes(utf8), START_MS + 3_000L), + "an SS3-looking continuation after a standalone Esc is plain text"); + + controller.simulateTerminalInput(esc, START_MS + 4_000L); + assertEquals("[hello]", controller.simulateTerminalInput( + "[hello]\r".getBytes(utf8), START_MS + 7_000L), + "a CSI-looking continuation after a standalone Esc is plain text"); + } + + /** + * A tab whose Claude process quit stays open. It must stop enumerating the + * session store: otherwise every exited tab keeps the shared identity worker + * busy and delays identity updates for the live tabs. + */ + @Test + void anExitedProcessStopsSchedulingIdentityScans() { + controller.simulateProcessStart(START_MS, true); + assertTrue(controller.scheduleIdentityRefresh(), + "a running tab keeps refreshing its session identity"); + + controller.simulateProcessNotRunning(); + assertFalse(controller.scheduleIdentityRefresh(), + "an exited process must not schedule identity scans"); + } + + /** + * Cursor keys and other escape sequences must be consumed as a whole: their + * final byte must not leak into the buffered line, or the recorded prompt + * would no longer match the one actually submitted and the identity scan + * could not confirm the conversation on screen. + */ + @Test + void escapeSequencesDoNotCorruptATerminalPrompt() { + byte[] enter = {'\r'}; + assertNull(controller.simulateTerminalInput( + "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS)); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 1); // Up + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'B'}, START_MS + 2); // Down + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'Z'}, START_MS + 3); // Shift+Tab + controller.simulateTerminalInput( + new byte[]{0x1b, '[', '5', '~'}, START_MS + 4); // PgUp + controller.simulateTerminalInput( + new byte[]{0x1b, '[', '6', '~'}, START_MS + 5); // PgDn + controller.simulateTerminalInput(new byte[]{0x1b, 'O', 'A'}, START_MS + 6); // SS3 Up + controller.simulateTerminalInput(new byte[]{0x1b, 'x'}, START_MS + 7); // Alt+x + + assertEquals("hello", controller.simulateTerminalInput(enter, START_MS + 8), + "escape sequences must not append their bytes to the typed prompt"); + } + + /** + * A multiline prompt pasted into the terminal arrives inside a bracketed + * paste ({@code ESC [ 200 ~} … {@code ESC [ 201 ~}). Its newlines are part + * of the prompt, not submissions, so the whole text is recorded — otherwise + * the prompt evidence would only keep the last pasted line. + */ + @Test + void bracketedPasteKeepsMultilinePromptsIntact() { + assertNull(controller.simulateTerminalInput( + "\u001b[200~".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS)); + assertNull(controller.simulateTerminalInput( + "first\nsecond".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 1), + "newlines inside a paste must not submit the line"); + controller.simulateTerminalInput( + "\u001b[201~".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2); + + assertEquals("first\nsecond", + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 3), + "the whole pasted prompt must be recorded"); + } + + @Test + void bracketedPasteRecordsCrLfAsASingleNewline() { + controller.simulateTerminalInput( + "\u001b[200~".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput( + "a\r\nb".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 1); + controller.simulateTerminalInput( + "\u001b[201~".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2); + + assertEquals("a\nb", + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 3)); + } + + /** + * A prompt corrected with Left-arrow is submitted differently from the keys + * pressed: the buffered line must apply the cursor move, or the recorded + * prompt does not match the conversation and the evidence is lost. + */ + @Test + void cursorEditsReconstructTheSubmittedPrompt() { + controller.simulateTerminalInput( + "helo".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'D'}, START_MS + 1); // Left + controller.simulateTerminalInput( + "l".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 2); + + assertEquals("hello", + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 3), + "the character typed after Left must land before the last one"); + } + + @Test + void homeEndDeleteAndBackspaceEditTheSubmittedPrompt() { + controller.simulateTerminalInput( + "ac".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'H'}, START_MS + 1); // Home + controller.simulateTerminalInput( + "0".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 2); // "0ac" + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'F'}, START_MS + 3); // End + controller.simulateTerminalInput( + "d".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 4); // "0acd" + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'H'}, START_MS + 5); // Home + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'C'}, START_MS + 6); // Right + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'C'}, START_MS + 7); // Right + controller.simulateTerminalInput(new byte[]{0x1b, '[', '3', '~'}, START_MS + 8); // Delete "c" + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'D'}, START_MS + 9); // Left + controller.simulateTerminalInput(new byte[]{0x7f}, START_MS + 10); // Backspace "0" + + assertEquals("ad", + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 11)); + } + + /** + * Supplementary Unicode characters (emoji and the like) are two UTF-16 code + * units. Backspace, Delete and the horizontal cursor keys must treat them + * as one character: splitting a surrogate pair leaves malformed text that + * can never match the message actually submitted, so the conversation on + * screen would not be recognised when the session is renamed. + */ + @Test + void backspaceAndDeleteRemoveWholeSupplementaryCharacters() { + java.nio.charset.Charset utf8 = java.nio.charset.StandardCharsets.UTF_8; + + controller.simulateTerminalInput("😀".getBytes(utf8), START_MS); + assertEquals("hello", + controller.simulateTerminalInput( + "\u007fhello\r".getBytes(utf8), START_MS + 1), + "Backspace after an emoji must remove the whole character"); + + controller.simulateTerminalInput("a😀b".getBytes(utf8), START_MS + 2); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'D'}, START_MS + 4); // Left + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'D'}, START_MS + 5); // Left + controller.simulateTerminalInput(new byte[]{0x1b, '[', '3', '~'}, START_MS + 6); // Delete + assertEquals("ab", + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 7), + "Left and Delete must step over and remove whole characters"); + } + + /** + * Cursor keys must be harmless at the ends of the line: Left at the start + * and Right at the end happen during ordinary navigation, and an exception + * raised by the input listener would abort the rest of the write. + */ + @Test + void cursorKeysAtTheLineBoundariesDoNotThrow() { + java.nio.charset.Charset utf8 = java.nio.charset.StandardCharsets.UTF_8; + + // Empty line: both directions are no-ops. + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'D'}, START_MS); // Left + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'C'}, START_MS + 1); // Right + + controller.simulateTerminalInput("ab".getBytes(utf8), START_MS + 2); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'F'}, START_MS + 3); // End + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'C'}, START_MS + 4); // Right at end + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'H'}, START_MS + 5); // Home + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'D'}, START_MS + 6); // Left at start + + assertEquals("ab", + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 7)); + } + + // ------------------------------------------------------------------------- + // prepareLaunchIdentity — captured before the process starts + // ------------------------------------------------------------------------- + + @Test + void prepareLaunchIdentity_newSession_hasNoIdButSnapshotsTheStore() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "old", "old"); + + ClaudeSessionController.LaunchIdentity identity = + ClaudeSessionController.prepareLaunchIdentity(work.toFile(), config, + io.github.nbplugins.claudecodegui.model.SessionMode.NEW, null); + + assertNull(identity.sessionId()); + assertEquals(Set.of("old"), identity.preStartSessionIds()); + assertEquals(config, identity.configDir()); + } + + @Test + void prepareLaunchIdentity_continueLast_picksTheNewestExistingSession() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "older", "older"); + setMtime(dir.resolve("older.jsonl"), START_MS - 5_000L); + writeSession(dir, "newer", "newer"); + setMtime(dir.resolve("newer.jsonl"), START_MS - 1_000L); + + ClaudeSessionController.LaunchIdentity identity = + ClaudeSessionController.prepareLaunchIdentity(work.toFile(), config, + io.github.nbplugins.claudecodegui.model.SessionMode.CONTINUE_LAST, null); + + assertEquals("newer", identity.sessionId()); + assertEquals(Set.of("older", "newer"), identity.preStartSessionIds()); + } + + @Test + void prepareLaunchIdentity_resumeSpecific_usesTheGivenId() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "saved", "saved"); + + ClaudeSessionController.LaunchIdentity identity = + ClaudeSessionController.prepareLaunchIdentity(work.toFile(), config, + io.github.nbplugins.claudecodegui.model.SessionMode.RESUME_SPECIFIC, + "saved"); + + assertEquals("saved", identity.sessionId()); + assertEquals(Set.of("saved"), identity.preStartSessionIds()); + } + + /** + * The prompt panel's "New session" action stops with a blank name: no rename + * is attempted, so the identity — and the session store with it — must not + * be resolved on the EDT before the PTY is stopped. + */ + @Test + void stopAndRename_blankName_doesNotScanTheStore() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "a", Set.of("a")); + controller.simulatePromptSubmittedAt(START_MS + 1_000L); + + long scansBefore = controller.simulateScanSequence(); + controller.stopAndRename(" "); + + assertEquals(scansBefore, controller.simulateScanSequence(), + "a blank name must not trigger a session-store scan"); + assertNull(controller.getActiveConfigDir(), "the process must still be stopped"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle()); + } + + /** + * The launch is prepared off the EDT, so the UI is usable while no process + * exists yet: the context menu asks {@link ClaudeSessionController#getOpenAIProxyUuid()} + * from the EDT, and the input panel asks {@link ClaudeSessionController#canSendPrompt()} + * before clearing the field. + */ + @Test + void noProcessYet_gettersAreSafeAndSendingIsUnavailable() { + assertNull(controller.getOpenAIProxyUuid(), + "asking for the proxy UUID before a launch must not throw"); + assertNull(controller.getLastAttemptedCommand(), + "asking for the attempted command before a launch must not throw"); + assertFalse(controller.canSendPrompt(), + "a prompt cannot be delivered before the PTY connector exists"); + } + + /** + * The counterpart of the test above: while this tab keeps writing its own + * conversation, another tab's newer file must not be adopted. + */ + @Test + void ownSessionIsKeptWhenAnotherTabWritesConcurrently() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // Both tabs are working: this tab's own file is written after its + // prompt, and the other tab's file even later. + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_000L); + + assertEquals("a", controller.resolveActiveSessionId(), + "a tab writing its own conversation must not adopt another tab's file"); + } finally { + tabB.stopProcess(); + } + } + + /** + * Resuming a saved conversation in-process on a coarse-timestamp filesystem: + * the new file existed before the launch (pre-launch snapshot) and its mtime + * is rounded below the prompt time, but the tab must still follow the switch. + */ + @Test + void inProcessResumeWithCoarseMtime_followsTheSwitch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "a", Set.of("a", "b")); + + // The TUI switches to "b" and the plugin sends a prompt; "b" is written + // after it but its mtime is rounded down into the same second. + controller.simulatePromptSubmittedAt(START_MS + 1_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 1_000L); + + controller.stopAndRename("Resumed Name"); + + assertEquals("Resumed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "a resumed saved conversation must be recognised despite its rounded mtime"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation left by the switch must not be renamed"); + } + + @Test + void newSessionWithCoarseMtime_resolvesAfterPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // A conversation that existed before the NEW tab was launched, so it is + // in the pre-launch snapshot and must stay ineligible. + writeSession(dir, "old", "old"); + setMtime(dir.resolve("old.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, null, Set.of("old")); + + // The first turn finishes inside one coarse (1 s) timestamp bucket: the + // file is written after the prompt but its mtime is rounded below it. + controller.simulatePromptSubmittedAt(START_MS + 1_500L); + writeSession(dir, "fresh", "fresh"); + setMtime(dir.resolve("fresh.jsonl"), START_MS + 1_000L); + + controller.stopAndRename("Fresh Name"); + + assertEquals("Fresh Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "fresh").customTitle(), + "a session whose mtime was rounded down must still be renamable"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "old").customTitle(), + "the pre-launch conversation must not be renamed instead"); + } + + /** + * A scan queued while the process is being stopped must not start with the + * new run's generation and the old run's inputs: it would publish (and + * re-claim) the stopped session after the stop released its claims. + */ + @Test + void stopProcess_resetsTheIdentityInputsWithTheGeneration() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + sessionDir(config, work); + model.setWorkingDirectory(work.toFile()); + + for (int i = 0; i < 500; i++) { + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "session", Set.of("session")); + controller.simulatePromptSubmittedAt(START_MS + 1_000L); + long before = controller.simulateProcessGeneration(); + + java.util.concurrent.atomic.AtomicReference + staleCapture = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicBoolean running = + new java.util.concurrent.atomic.AtomicBoolean(true); + Thread sampler = new Thread(() -> { + while (running.get()) { + ClaudeSessionController.IdentityCapture c = + controller.simulateCaptureIdentityInputs(); + if (c.generation() > before && (c.hasConfigDir() || c.since() != 0)) { + staleCapture.compareAndSet(null, c); + } + } + }); + sampler.setDaemon(true); + sampler.start(); + + controller.stopProcess(); + running.set(false); + sampler.join(); + + assertNull(staleCapture.get(), + "a scan starting after the generation bump captured the stopped " + + "run's identity inputs on iteration " + i); + } + } + + @Test + void staleExitCallback_doesNotReleaseTheReplacementRun() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // First run of this tab. + writeSession(dir, "old", "old"); + setMtime(dir.resolve("old.jsonl"), START_MS - 5_000L); + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "old", Set.of()); + long oldGeneration = controller.simulateProcessGeneration(); + + // Save & Switch restarts the tab before the old waiter reports its exit. + controller.stopProcess(); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 4_000L); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "b", Set.of()); + controller.simulateProcessExitAt(oldGeneration); + + // A sibling started later must still see "b" as owned by this tab. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 3_000L, true); + tabB.simulateLiveSession(config, null, Set.of("old")); + try { + assertNull(tabB.resolveActiveSessionId(), + "a late exit callback of the replaced process must not " + + "release the new run's claim"); + } finally { + tabB.stopProcess(); + } + } + + @Test + void scanStartedBeforeRestart_doesNotPublishAfterReplacement() throws Exception { + java.nio.file.Path storeA = tmpDir.resolve("configA"); + java.nio.file.Path storeB = tmpDir.resolve("configB"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dirA = sessionDir(storeA, work); + java.nio.file.Path dirB = sessionDir(storeB, work); + writeSession(dirA, "old", "old"); + setMtime(dirA.resolve("old.jsonl"), START_MS + 2_000L); + writeSession(dirB, "new", "new"); + setMtime(dirB.resolve("new.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(storeA, "old", Set.of("old")); + long staleGeneration = controller.simulateProcessGeneration(); + + // Save & Switch restarts the tab into another profile / store before + // the background scan finishes. + controller.stopProcess(); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateActiveSession(storeB, "new", Set.of("new")); + + controller.simulatePublishStaleScanResult(staleGeneration, "old"); + + assertEquals("new", controller.resolveActiveSessionId(), + "a scan started before the restart must not overwrite the " + + "identity of the replaced run"); + } + + @Test + void olderScan_doesNotOverwriteANewerScanResult() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS + 1_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 2_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, null, Set.of()); + + // A slow worker scan started before the synchronous one, but finishes + // (and publishes) after it. + long slowScan = controller.simulateBeginIdentityScan(); + long fastScan = controller.simulateBeginIdentityScan(); + controller.simulatePublishScanResult( + controller.simulateProcessGeneration(), fastScan, "b"); + controller.simulatePublishScanResult( + controller.simulateProcessGeneration(), slowScan, "a"); + + // Remove the files so the final resolve cannot mask a wrong identity. + java.nio.file.Files.delete(dir.resolve("a.jsonl")); + java.nio.file.Files.delete(dir.resolve("b.jsonl")); + + assertEquals("b", controller.resolveActiveSessionId(), + "a scan that started earlier must not undo a newer scan's result"); + } + + @Test + void claimsAreScopedToTheirSessionStore() throws Exception { + java.nio.file.Path storeA = tmpDir.resolve("configA"); + java.nio.file.Path storeB = tmpDir.resolve("configB"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dirA = sessionDir(storeA, work); + java.nio.file.Path dirB = sessionDir(storeB, work); + + // A live tab in store A runs a session "shared". + writeSession(dirA, "shared", "shared"); + setMtime(dirA.resolve("shared.jsonl"), START_MS - 5_000L); + ClaudeSessionModel modelA = new ClaudeSessionModel(); + modelA.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabA = + new ClaudeSessionController(modelA, Collections::emptyList); + tabA.simulateProcessStart(START_MS - 500L, true); + tabA.simulateLiveSession(storeA, "shared", Set.of("shared")); + + // Our tab in store B switches in-process from "previous" to a copy of + // the same conversation (same ID, different file). + writeSession(dirB, "previous", "previous"); + setMtime(dirB.resolve("previous.jsonl"), START_MS - 1_000L); + writeSession(dirB, "shared", "shared"); + setMtime(dirB.resolve("shared.jsonl"), START_MS + 2_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(storeB, "previous", Set.of("previous", "shared")); + try { + assertEquals("shared", controller.resolveActiveSessionId(), + "a claim from another store must not hide this store's file"); + } finally { + tabA.stopProcess(); + } + } + + @Test + void stopAndRename_usesResumeSessionId() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "resumed", "old"); + setMtime(dir.resolve("resumed.jsonl"), START_MS - 5_000L); + writeSession(dir, "newer", "newer"); + setMtime(dir.resolve("newer.jsonl"), START_MS - 1_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "resumed", Set.of("resumed", "newer")); + + controller.stopAndRename("Resumed Name"); + + assertEquals("Resumed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "resumed").customTitle()); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "newer").customTitle(), + "a newer unrelated session must not be renamed"); + } + + /** + * This tab's process quits on its own while Save & Switch is taking the + * snapshot of the claims it must carry into the final lookup. The exit + * waiter moves the claims from the live registry into the preserved set; + * the snapshot must observe the transition as a whole. Reading the two sets + * apart would miss the shared conversation this tab held, and the scan + * would then rename an unrelated eligible file even though the shared one + * is the newest. + */ + @Test + void aStopSnapshotsTheClaimsTheExitWaiterIsReleasing() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + writeSession(dir, "unrelated", "unrelated"); + setMtime(dir.resolve("unrelated.jsonl"), START_MS + 5_000L); + + // Another live tab resumed the same saved conversation. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "shared", Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "shared", Set.of("shared", "unrelated")); + // The process quits exactly while the stop path snapshots the claims. + controller.simulateClaimSnapshotHook(controller::simulateProcessExit); + + try { + controller.stopAndRename("Shared Title"); + } finally { + tabB.stopProcess(); + } + + assertEquals("Shared Title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "shared").customTitle(), + "the conversation this tab held is renamed even after its process quit"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "unrelated").customTitle(), + "an unrelated file must not be renamed"); + } + + @Test + void stopAndRename_skipsUnchangedTitle() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "s1", "slug"); + java.nio.file.Files.writeString(dir.resolve("s1.jsonl"), + "{\"type\":\"custom-title\",\"customTitle\":\"Same\",\"sessionId\":\"s1\"}\n", + java.nio.file.StandardOpenOption.APPEND); + setMtime(dir.resolve("s1.jsonl"), START_MS - 1_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "s1", Set.of("s1")); + + controller.stopAndRename("Same"); + + assertEquals(1, countCustomTitles(dir.resolve("s1.jsonl"), "Same"), + "repeated stops must not append duplicate custom-title lines"); + } + + @Test + void stopAndRename_blankName_doesNothing() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "s1", "slug"); + setMtime(dir.resolve("s1.jsonl"), START_MS - 1_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "s1", Set.of("s1")); + + controller.stopAndRename(" "); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "s1").customTitle()); + } + + /** + * A NEW tab submits a prompt whose text the plugin never sees (recalled + * from the TUI history or completed by Tab). The first file with a user + * record written since that submission is only a provisional guess: the + * tab's own file appears later with the same kind of record, and the + * evidence cannot tell which one this run wrote. The stop must not write + * the entered name into either of them. + */ + @Test + void aStopDoesNotRenameAnUnknownTextGuessWhileAnotherFileCompares() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + // The recalled prompt is submitted while no file of this run exists; + // the only file written since then is adopted provisionally. + controller.simulatePromptText(null); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "sibling prompt", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the only file written since the submission is provisionally adopted"); + controller.simulateAwaitIdentityScan(); + + // This tab's own file appears later: another ID, the same submission, + // and no text that could match it as this run's evidence. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "own prompt", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the provisional guess must not be renamed as this tab's"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing file must not be renamed as this tab's"); + } + + /** + * A checkbox answer is forwarded asynchronously: the worker waits between + * the toggles and sends the submitting Right arrow last, and the TUI keeps + * the menu open while it does. Terminal input arriving in that window + * answers the menu, so a line typed there is not a prompt and must not + * become prompt evidence. + */ + @Test + void aCheckboxAnswerKeepsTheMenuUntilTheSubmittingArrow() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select files", List.of( + checkbox("1", false), checkbox("2", false)), 0)); + + CountDownLatch firstToggle = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("1".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + firstToggle.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("MULTI:1"); + + assertTrue(firstToggle.await(5, TimeUnit.SECONDS), + "the worker forwarded the first toggle"); + + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the TUI is still in the checkbox menu: the typed line is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the menu is retired once the answer has been forwarded"); + } + + /** + * A type-input answer activates its option with a digit and pauses before + * forwarding the text. The TUI cursor is in that option's editor by then, + * so a line typed in the terminal during the pause is editor input — + * including its leading digit, which no longer selects another item. + */ + @Test + void aTypeAnswerMovesTheMenuFocusToItsEditor() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3", null, false, false, true)), + 0)); + + CountDownLatch typing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("auto answer".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + typing.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("TYPE:3:auto answer"); + assertTrue(typing.await(5, TimeUnit.SECONDS), + "the worker reached the option's editor"); + + assertNull(controller.simulateTerminalInput( + "1feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the cursor is in the option's editor: the typed line is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the menu is retired once the answer has been forwarded"); + } + + /** + * The recorder classifies terminal input against the menu instance kept in + * the model, while the screen detector parses a fresh instance on every + * poll. An answer worker that recorded its forwarded cursor on the detected + * instance would never match, and a digit typed during the pause would + * retire the menu although the cursor sits in its text-input editor: the + * line typed there then becomes prompt evidence. + */ + @Test + void aTypeAnswerTracksFocusOnTheActiveMenuInstance() throws Exception { + List screen = java.nio.file.Files.readAllLines(java.nio.file.Path.of( + java.util.Objects.requireNonNull(getClass().getResource( + "/io/github/nbplugins/claudecodegui/process/screen-content-detector" + + "/detect-choice-menu/ultraplan-tell-claude.src.txt")) + .toURI())); + + ClaudeSessionModel m2 = new ClaudeSessionModel(); + ClaudeSessionController c2 = new ClaudeSessionController(m2, () -> screen); + CountDownLatch release = new CountDownLatch(1); + try { + m2.setActiveChoiceMenu(new ChoiceMenuModel( + "Claude has written up a plan and is ready to execute." + + " Would you like to proceed?", + List.of(new ChoiceMenuModel.Option("Yes, auto-accept edits", "1"), + new ChoiceMenuModel.Option("Yes, manually approve edits", "2"), + new ChoiceMenuModel.Option("No, refine with Ultraplan on" + + " Claude Code on the web", "3"), + new ChoiceMenuModel.Option("Tell Claude what to change", "4", + null, false, false, true)), + 0)); + + CountDownLatch typing = new CountDownLatch(1); + c2.simulatePtyWrite(bytes -> { + if ("auto answer".equals( + new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + typing.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + c2.writePtyAnswer("TYPE:4:auto answer"); + assertTrue(typing.await(5, TimeUnit.SECONDS), + "the worker reached the option's editor"); + + assertNull(c2.simulateTerminalInput( + "1feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the cursor is in the option's editor: the typed line is the menu's"); + assertNull(c2.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (m2.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(m2.getActiveChoiceMenu(), + "the menu is retired once the answer has been forwarded"); + } finally { + release.countDown(); + c2.stopProcess(); + } + } + + /** + * An arrow-key answer navigates the picker row by row and submits with + * Enter last. The picker stays open until that Enter, so a line typed in + * the terminal meanwhile is its search text or an answer, not a prompt. + */ + @Test + void anArrowAnswerKeepsThePickerUntilItsEnter() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an option", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + byte[] down = {0x1b, '[', 'B'}; + CountDownLatch moved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("ARROW:1"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker moved the picker cursor"); + + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the picker is still open: the typed line is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the picker is retired once the answer has been forwarded"); + } + + /** + * A NEW tab established {@code a} as its own file. Another tab later + * resumes {@code a} explicitly and becomes its owner; that must not hide + * the file from the tab that wrote it in favour of an unrelated transcript + * that merely recorded the same common prompt. + */ + @Test + void aLaterResumeByAnotherTabKeepsTheOwnFileEligible() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "continue", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_000L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the file holding the submission is this tab's own"); + controller.simulateAwaitIdentityScan(); + + // Another tab's unrelated conversation recorded the same common text + // earlier; this tab's file stays the newer one. + writeSession(dir, "c", "c"); + appendUserPrompt(dir, "c", "continue", START_MS + 10_800L); + setMtime(dir.resolve("c.jsonl"), START_MS + 10_900L); + + // The sibling tab resumes this tab's conversation explicitly and + // becomes its owner. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 12_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a")); + + try { + assertEquals("a", controller.resolveActiveSessionId(), + "the resumed file is still the conversation this tab wrote"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "c").customTitle(), + "an unrelated transcript with the same text must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the guess stays ambiguous while the file is shared"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A provisional guess must not become safe to rename just because a + * command cancelled in the terminal replaced the prompt evidence: the + * cancelled picker identified no writer, and the tab's own competing file + * still makes the two indistinguishable. + */ + @Test + void aCancelledPickerDoesNotMakeAGuessSafeToRename() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the only file written since the submission is provisionally adopted"); + controller.simulateAwaitIdentityScan(); + + // This tab's own file appears later and holds the same text. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "continue", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + controller.simulateAwaitIdentityScan(); + + // The user opens the /resume picker and cancels it with Esc: no + // conversation was resolved, but the prompt evidence is gone. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 20_000L); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 20_100L); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the guess must not be renamed after the cancelled picker"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing own file must not be renamed either"); + } + + /** + * An item numbered 10 or above cannot be typed into the picker: the answer + * is forwarded as Up/Down keys followed by Enter, with a pause between + * them. The menu stays open in the TUI until that Enter, so a line typed + * in the terminal meanwhile answers the menu and is not a prompt. + */ + @Test + void aTwoDigitAnswerKeepsTheMenuUntilItsEnter() throws Exception { + List options = new java.util.ArrayList<>(); + for (int i = 1; i <= 12; i++) { + options.add(new ChoiceMenuModel.Option("Item " + i, String.valueOf(i))); + } + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an item", options, 0, false, 1)); + + byte[] down = {0x1b, '[', 'B'}; + CountDownLatch moved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("10"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker started moving the picker cursor"); + + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the picker is still open: the typed line is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the picker is retired once the answer has been forwarded"); + } + + /** + * A guess adopted on the strength of a submitted prompt keeps that evidence + * when a later refresh only returns the same file by its modification time: + * assistant output written after a cancelled {@code /resume} adds no + * evidence about who wrote the file, and the competing conversation with + * an earlier user record must still keep the stop from renaming it. + */ + @Test + void anMtimeOnlyRefreshKeepsTheAdoptedGuessAmbiguous() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the only file written since the submission is provisionally adopted"); + controller.simulateAwaitIdentityScan(); + + // This tab's own file appears later and holds the same text. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "continue", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + controller.simulateAwaitIdentityScan(); + + // The user opens the /resume picker and cancels it with Esc: no + // conversation was resolved, but the prompt evidence is gone. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 20_000L); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 20_100L); + controller.simulateAwaitIdentityScan(); + + // The reply stored after the cancelled picker touches the tracked file + // again; the refresh can only return it by its modification time. + appendAssistantLine(dir, "b", START_MS + 21_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 21_000L); + assertEquals("b", controller.resolveActiveSessionId(), + "the modification time still names the tracked file"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "an mtime-only refresh must not make the guess safe to rename"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing own file must not be renamed either"); + } + + /** + * Filesystems may round modification times below the submission time, so + * the resolver adopts a sibling file written in the same second. When this + * tab's own file then appears in that same rounded bucket with the same + * prompt, the ambiguity check must search with the same relaxed cutoff — + * otherwise it misses the competitor and renames the sibling's file. + */ + @Test + void aRoundedMtimeCompetitorStillBlocksTheRename() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + + // The sibling's file was written just after the submission, but its + // modification time rounds down below it. + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 9_999L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the rounded file is still the one written since the submission"); + controller.simulateAwaitIdentityScan(); + + // This tab's own file lands in the same rounded bucket with the same + // text: the two are indistinguishable. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "continue", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 9_999L); + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the rounded sibling must not be renamed while both match"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing own file must not be renamed either"); + } + + /** + * A launch-known tab switches to the conversation a live sibling writes; + * no evidence pass may adopt that file, so the tab keeps tracking the + * conversation the switch left behind. A prompt submitted afterwards moves + * the evidence cutoff past the switch without showing where the switch + * went, and it was also written into the launch conversation whenever the + * picker selection never took effect. Neither may authorize the rename: the + * entered name cannot be attributed to the conversation the process left. + */ + @Test + void aPromptBeforeTheDestinationRecordDoesNotUnblockTheRename() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 1_000L); + + // A sibling tab launched the conversation the picker will select. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS - 500L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user opens /resume and picks the sibling's conversation. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 10_100L); + controller.simulateAwaitIdentityScan(); + + // A prompt submitted after the switch; no file records it, so the + // scan has nothing to follow the switch with. + controller.simulateTerminalInput( + "later\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 20_000L); + controller.simulateAwaitIdentityScan(); + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation stays tracked while the destination is unknown"); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "a submission after an unresolved switch must not rename the" + + " conversation the process left"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the live sibling's conversation must not be renamed either"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The counterpart of the previous test: once a record written after the + * switch resolves the destination, the entered name goes there. The + * post-switch evidence must not block a rename the evidence does support. + */ + @Test + void aSwitchResolvedByTheDestinationRecordStaysRenamable() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + // The picker switches to a saved conversation no other tab holds. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 10_100L); + + // The destination records the prompt submitted after the switch. + controller.simulateTerminalInput( + "later\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 20_000L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "later", START_MS + 20_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 20_600L); + assertEquals("b", controller.resolveActiveSessionId(), + "the record written after the switch names the destination"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("Switched Name"); + + assertEquals("Switched Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the resolved destination is the conversation the name belongs to"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation left by the switch must not be renamed"); + } + + /** + * An arrow-key answer pauses between its navigation and its Enter. The user + * can answer the menu with Enter in the terminal during that pause and then + * type the next prompt; the worker's Enter submits that draft as a real + * prompt, so its text must become the prompt evidence — dropping it leaves + * the stop-time lookup unable to follow the conversation. + */ + @Test + void theWorkersEnterAfterAnEarlyMenuAnswerIsRecorded() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an option", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + byte[] down = {0x1b, '[', 'B'}; + java.util.concurrent.CountDownLatch moved = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("ARROW:1"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker paused before its submitting Enter"); + + // The user answers the menu in the terminal and types the next prompt, + // leaving it unsubmitted for the worker's Enter. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.simulateTerminalInput( + "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 200L); + assertNull(controller.simulateLastPromptText(), + "nothing is submitted until the worker's Enter"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (controller.simulateLastPromptText() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + + assertEquals("hello", controller.simulateLastPromptText(), + "the Enter submits the draft, so it is the prompt evidence"); + assertTrue(controller.simulatePromptSubmittedAtValue() > 0L, + "the submission time is recorded with it"); + } + + /** + * The early-answer recovery also submits whatever command the user typed + * after answering the menu. {@code /clear} switches the conversation just + * like {@code /resume}, so its line is no prompt's text and the switch has + * to advance — recording it as an ordinary submission would leave the scan + * following the conversation the tab is no longer on. + */ + @Test + void theWorkersEnterSubmittingClearIsAConversationSwitch() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an option", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + byte[] down = {0x1b, '[', 'B'}; + java.util.concurrent.CountDownLatch moved = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("ARROW:1"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker paused before its submitting Enter"); + + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.simulateTerminalInput( + "/clear".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 200L); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (controller.simulatePromptSubmittedAtValue() == 0L + && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + + assertNull(controller.simulateLastPromptText(), + "a command switches the conversation, it is not prompt evidence"); + assertTrue(controller.simulatePromptSubmittedAtValue() > 0L, + "the submission is recorded for the scan"); + } + + /** + * The worker's Enter publishes its evidence inside the recorder lock: a + * terminal writer that submitted after this one must keep its newer text + * and time, or the stop-time lookup follows a prompt that is no longer the + * last one the conversation received. + */ + @Test + void theWorkersEnterDoesNotReplaceNewerPromptEvidence() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an option", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + byte[] down = {0x1b, '[', 'B'}; + byte[] enterKey = {'\r'}; + java.util.concurrent.CountDownLatch moved = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch entered = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } else if (java.util.Arrays.equals(bytes, enterKey)) { + entered.countDown(); + } + }); + + controller.writePtyAnswer("ARROW:1"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker paused before its submitting Enter"); + + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.simulateTerminalInput( + "older".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 200L); + // A terminal writer submits a newer prompt before this older draft is + // published; its timestamp is later than the worker's capture. + long newerAt = System.currentTimeMillis() + 60_000L; + controller.simulatePromptText("newer"); + controller.simulatePromptSubmittedAt(newerAt); + + release.countDown(); + assertTrue(entered.await(5, TimeUnit.SECONDS), + "the worker submits the line"); + // The worker publishes right after writing its Enter: give it a moment + // to do so, then check that it left the newer evidence alone. + long deadline = System.currentTimeMillis() + 1_000L; + while (controller.simulatePromptSubmittedAtValue() == newerAt + && System.currentTimeMillis() < deadline) { + Thread.sleep(10L); + } + + assertEquals("newer", controller.simulateLastPromptText(), + "the newer submission keeps the prompt evidence"); + assertEquals(newerAt, controller.simulatePromptSubmittedAtValue(), + "and its submission time comes back with it"); + } + + /** + * {@code /resume} only opens the picker: cancelling it with Esc leaves the + * conversation where it was, so the stop must still save the name into the + * launch conversation instead of treating the switch as unresolved. + */ + @Test + void aCancelledPickerKeepsTheLaunchConversationRenamable() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + // The user types /resume, its picker opens and Esc cancels it; then the + // stop dialog is opened without any further keystroke. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_100L); + + controller.stopAndRename("A-title"); + + assertEquals("A-title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "a cancelled picker never changed the conversation"); + } + + /** + * The screen poll publishes the picker a few hundred milliseconds after the + * command, so a user who presses Esc immediately cancels a menu the model + * has not seen yet. That Esc cancels the very picker the plugin is waiting + * on, so the switch must be rolled back just the same: otherwise the stop + * treats the conversation as switched and drops the name. + */ + @Test + void aPickerCancelledBeforeItIsPublishedKeepsTheLaunchConversationRenamable() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + // /resume is submitted and Esc cancels the picker before the screen + // detector ever publishes it. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_100L); + + controller.stopAndRename("A-title"); + + assertEquals("A-title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the unpublished picker was cancelled, so nothing switched"); + } + + /** + * The panel's Cancel button is the other way to dismiss the picker: like a + * terminal Esc it must roll the pending switch back even when the screen + * poll has not published the menu yet. + */ + @Test + void aPickerCancelledFromThePanelBeforeItIsPublishedKeepsTheLaunchConversationRenamable() + throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + controller.simulateConnector( + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p)); + + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + // The user presses the panel's Cancel before the picker is published. + controller.cancelPrompt(); + + controller.stopAndRename("A-title"); + + assertEquals("A-title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the cancelled picker never switched the conversation"); + } finally { + p.destroyForcibly(); + } + } + + /** + * Cancelling a published picker from the panel must retire it at once: the + * plugin writes the Esc itself, so the screen poll would keep reporting the + * menu. A panel prompt sent before that poll then has its Enter mistaken + * for a picker selection — the evidence is dropped and the switch is + * recorded again, although the conversation never changed. + */ + @Test + void aPickerCancelledFromThePanelIsRetiredBeforeTheNextSend() throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + controller.simulateConnector( + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p)); + + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + + // The user cancels the published picker from the panel and sends + // the next prompt before the screen poll clears the menu. + controller.cancelPrompt(); + controller.simulateGuiSend("hello", START_MS + 20_000L); + controller.completeGuiSend(); + + controller.stopAndRename("A-title"); + + assertEquals("A-title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the cancelled picker never switched the conversation"); + } finally { + p.destroyForcibly(); + } + } + + /** + * Cancelling the picker also puts back the prompt evidence the resume + * command replaced, so the conversation the tab was on before stays the + * submission the stop-time lookup follows. + */ + @Test + void aCancelledPickerRestoresThePromptEvidence() { + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 5_000L); + + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + assertEquals(START_MS + 10_000L, controller.simulatePromptSubmittedAtValue(), + "expected: the command submission is the newest evidence"); + + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_100L); + // A standalone Esc only becomes a cancellation once the next input + // shows it was not part of an escape sequence: a second Esc is that + // input and consumes it. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_150L); + + assertEquals("continue", controller.simulateLastPromptText(), + "the prompt submitted before the cancelled picker is the evidence again"); + assertEquals(START_MS + 5_000L, controller.simulatePromptSubmittedAtValue(), + "its submission time comes back with it"); + } + + /** + * The cancelled command also puts back the external-input cutoff: the + * {@code /resume} Enter is not a submission to the conversation, so it must + * not leave the cutoff at the command's own time, where the earlier prompt + * would look stale to the stop-time lookup. + */ + @Test + void aCancelledPickerRestoresTheExternalInputCutoff() { + controller.simulateExternalInput(START_MS + 4_000L); + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 5_000L); + + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + assertEquals(START_MS + 10_000L, controller.simulateExternalInputAtValue(), + "expected: the submission itself is the newest terminal input"); + + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_100L); + // A standalone Esc becomes a cancellation once the next input shows it + // was not part of a sequence: a second Esc is that input. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_150L); + + assertEquals(START_MS + 4_000L, controller.simulateExternalInputAtValue(), + "the cancelled command never reached the conversation"); + } + // ------------------------------------------------------------------------- // No-op listener base class // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 53cab19d..93b30c3e 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -195,4 +195,429 @@ void renameSession_throwsWhenFileNotFound() { assertThrows(IOException.class, () -> ClaudeSessionStore.renameSession(workDir, tmpDir, "no-such-id", "Name")); } + + // ------------------------------------------------------------------------- + // Session identity helpers + // ------------------------------------------------------------------------- + + private static final java.time.Instant OLD_TS = java.time.Instant.parse("2024-01-01T10:00:00Z"); + + /** Fixed filesystem mtime base; tests derive deterministic mtimes from it. */ + private static final long BASE_MTIME = 1_700_000_000_000L; + + private void writeTimedSession(Path sessionDir, String id, java.time.Instant ts, + long mtimeMillis) throws IOException { + Path file = sessionDir.resolve(id + ".jsonl"); + writeJsonl(file, + "{\"sessionId\":\"" + id + "\",\"slug\":\"slug-" + id + + "\",\"timestamp\":\"" + ts + "\",\"type\":\"summary\"}"); + Files.setLastModifiedTime(file, + java.nio.file.attribute.FileTime.fromMillis(mtimeMillis)); + } + + @Test + void listSessionIds_returnsAllJsonlStems() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "s1", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "s2", OLD_TS, BASE_MTIME); + writeJsonl(sessionDir.resolve("notes.txt"), "ignored"); + + assertEquals(java.util.Set.of("s1", "s2"), + ClaudeSessionStore.listSessionIds(workDir, tmpDir)); + } + + @Test + void listSessionIds_missingDir_returnsEmptySet() { + assertTrue(ClaudeSessionStore.listSessionIds(tmpDir.resolve("nope"), tmpDir).isEmpty()); + } + + @Test + void findLatestSessionId_usesFileMtime() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "old", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "new", OLD_TS, BASE_MTIME + 1_000L); + + assertEquals("new", ClaudeSessionStore.findLatestSessionId(workDir, tmpDir)); + } + + @Test + void findLatestSessionId_ignoresUnparseableNewestFile() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "older", OLD_TS, BASE_MTIME); + // No timestamp → parseSessionFile() returns null, but mtime says it is newest. + Path broken = sessionDir.resolve("broken.jsonl"); + writeJsonl(broken, "{\"sessionId\":\"broken\"}"); + Files.setLastModifiedTime(broken, + java.nio.file.attribute.FileTime.fromMillis(BASE_MTIME + 1_000L)); + + assertEquals("broken", ClaudeSessionStore.findLatestSessionId(workDir, tmpDir), + "mtime-based lookup must not depend on JSONL content"); + } + + @Test + void findSessionIdCreatedAfter_skipsFilesPresentBeforeLaunch() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "preexisting", OLD_TS, BASE_MTIME); + java.util.Set before = ClaudeSessionStore.listSessionIds(workDir, tmpDir); + writeTimedSession(sessionDir, "created", OLD_TS, BASE_MTIME + 1_000L); + + assertEquals("created", ClaudeSessionStore.findSessionIdCreatedAfter( + workDir, tmpDir, before, 0L)); + } + + @Test + void findSessionIdCreatedAfter_prefersFileNewerThanLaunch() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "stale-new", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "fresh", OLD_TS, BASE_MTIME + 2_000L); + + assertEquals("fresh", ClaudeSessionStore.findSessionIdCreatedAfter( + workDir, tmpDir, java.util.Set.of(), BASE_MTIME + 1_000L)); + } + + @Test + void findLatestSessionIdWrittenSince_returnsNullWhenNothingNewer() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "old", OLD_TS, BASE_MTIME); + + assertNull(ClaudeSessionStore.findLatestSessionIdWrittenSince( + workDir, tmpDir, BASE_MTIME + 1_000L, java.util.Set.of())); + } + + @Test + void findLatestSessionIdWrittenSince_picksNewestAndSkipsExcluded() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "mine", OLD_TS, BASE_MTIME + 1_000L); + writeTimedSession(sessionDir, "other-tab", OLD_TS, BASE_MTIME + 5_000L); + + // "other-tab" is newer but belongs to another tab. + assertEquals("mine", ClaudeSessionStore.findLatestSessionIdWrittenSince( + workDir, tmpDir, BASE_MTIME, java.util.Set.of("other-tab"))); + assertEquals("other-tab", ClaudeSessionStore.findLatestSessionIdWrittenSince( + workDir, tmpDir, BASE_MTIME, java.util.Set.of())); + } + + @Test + void listSessionIdsWrittenSince_returnsNewestFirstAndSkipsExcluded() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "old", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "mid", OLD_TS, BASE_MTIME + 1_000L); + writeTimedSession(sessionDir, "new", OLD_TS, BASE_MTIME + 2_000L); + + assertEquals(java.util.List.of("new", "mid"), + ClaudeSessionStore.listSessionIdsWrittenSince( + workDir, tmpDir, BASE_MTIME + 500L, java.util.Set.of("old"), 8)); + assertEquals(java.util.List.of("new"), + ClaudeSessionStore.listSessionIdsWrittenSince( + workDir, tmpDir, BASE_MTIME + 500L, java.util.Set.of("old"), 1), + "the limit must keep the newest files"); + assertTrue(ClaudeSessionStore.listSessionIdsWrittenSince( + workDir, tmpDir, BASE_MTIME + 5_000L, java.util.Set.of(), 8).isEmpty(), + "files older than the cutoff are not candidates"); + } + + @Test + void listSessionIdsWrittenSince_appliesIncludedIdsBeforeTheLimit() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "wanted", OLD_TS, BASE_MTIME + 1_000L); + for (int i = 1; i <= 8; i++) { + writeTimedSession(sessionDir, "noise" + i, OLD_TS, + BASE_MTIME + 1_000L + i * 100L); + } + + assertEquals(java.util.List.of("wanted"), + ClaudeSessionStore.listSessionIdsWrittenSince( + workDir, tmpDir, BASE_MTIME, java.util.Set.of(), + java.util.Set.of("wanted"), 8), + "newer unrelated files must not fill the limited list"); + } + + // ------------------------------------------------------------------------- + // containsUserPromptSince + // ------------------------------------------------------------------------- + + private static String userLine(String text, java.time.Instant ts) { + return "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"" + + text + "\"},\"timestamp\":\"" + ts + "\"}"; + } + + @Test + void containsUserPromptSince_findsPromptWrittenAfterSubmission() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, + userLine("earlier question", OLD_TS), + userLine("the prompt from this tab", OLD_TS.plusSeconds(60))); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.plusSeconds(30).toEpochMilli(), "the prompt from this tab")); + } + + @Test + void containsUserPromptSince_ignoresOlderMessagesWithTheSameText() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, userLine("continue", OLD_TS)); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.plusSeconds(30).toEpochMilli(), "continue"), + "a message written before the prompt is not evidence of a shared resume"); + } + + @Test + void containsUserPromptSince_acceptsContentBlocks() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + String ts = OLD_TS.plusSeconds(60).toString(); + writeJsonl(file, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"prompt in a block\"}]},\"timestamp\":\"" + + ts + "\"}"); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "prompt in a block")); + } + + @Test + void containsUserPromptSince_requiresTheWholePrompt() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, userLine("Summarize yesterday's changes", OLD_TS.plusSeconds(60))); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "yes"), + "a message that merely contains the prompt is not evidence"); + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "yesterday"), + "a message that merely contains the prompt is not evidence"); + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "Summarize yesterday's changes")); + } + + @Test + void containsUserPromptSince_findsPromptPushedFarFromTheEnd() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + + StringBuilder sb = new StringBuilder(); + sb.append(userLine("the prompt from this tab", OLD_TS.plusSeconds(60))).append('\n'); + // A long assistant turn pushes the prompt beyond the first block read + // from the end of the file (~600 KiB of records). + String filler = "x".repeat(8 * 1024); + for (int i = 0; i < 80; i++) { + sb.append("{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"") + .append(filler).append("\"},\"timestamp\":\"") + .append(OLD_TS.plusSeconds(61 + i)).append("\"}\n"); + } + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.plusSeconds(30).toEpochMilli(), "the prompt from this tab")); + } + + /** + * A record longer than the read block spans several blocks; the backward + * scan must still reassemble the record boundaries correctly and find the + * prompt that follows it. + */ + @Test + void containsUserPromptSince_findsPromptAfterRecordsSpanningSeveralBlocks() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + + StringBuilder sb = new StringBuilder(); + sb.append(userLine("prompt after huge records", OLD_TS.plusSeconds(60))).append('\n'); + // Each record is about 1 MiB, i.e. two read blocks of 512 KiB. + String filler = "y".repeat(1024 * 1024); + for (int i = 0; i < 3; i++) { + sb.append("{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"") + .append(filler).append("\"},\"timestamp\":\"") + .append(OLD_TS.plusSeconds(61 + i)).append("\"}\n"); + } + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.plusSeconds(30).toEpochMilli(), "prompt after huge records")); + } + + /** + * A file that is one record with no newline at all must be scanned to its + * very beginning without losing the first bytes kept across blocks. + */ + @Test + void containsUserPromptSince_scansAFileMadeOfOneHugeRecord() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + + StringBuilder sb = new StringBuilder(); + sb.append("{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"") + .append("z".repeat(3 * 1024 * 1024)).append("\"}"); + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "never present")); + } + + /** + * A rename appends a {@code custom-title} record whose timestamp is + * sampled before the file is read, so it can be dated before a user record + * already in the file. The backward scan must not treat that metadata + * record as the end of the evidence window and hide the prompt above it, + * for either evidence API. + */ + @Test + void containsUserPromptSince_findsThePromptAboveAnOlderTitleRecord() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + // Dated in the future so the title appended by the rename below is + // older than the prompt, as when the clock is sampled before the read. + java.time.Instant promptAt = java.time.Instant.now().plusSeconds(60); + writeJsonl(file, userLine("the prompt from this tab", promptAt)); + + ClaudeSessionStore.renameSession(workDir, tmpDir, "s1", "Renamed"); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + promptAt.minusSeconds(30).toEpochMilli(), "the prompt from this tab"), + "a title record dated before the prompt must not end the scan"); + assertTrue(ClaudeSessionStore.containsUserMessageSince(workDir, tmpDir, "s1", + promptAt.minusSeconds(30).toEpochMilli()), + "the unknown-text evidence must not be hidden either"); + } + + @Test + void containsUserPromptSince_noEvidence_returnsFalse() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeJsonl(sessionDir.resolve("s1.jsonl"), + userLine("another tabs prompt", OLD_TS.plusSeconds(60))); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "this tabs prompt")); + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "missing", + OLD_TS.toEpochMilli(), "whatever")); + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), " ")); + } + + /** + * Claude writes system-injected messages with {@code isMeta}: they are + * {@code type: "user"} records carrying text this tab never typed, so one + * containing the same text must not be taken for this tab's prompt. + */ + @Test + void containsUserPromptSince_ignoresMetaRecordsWithTheSameText() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeJsonl(sessionDir.resolve("s1.jsonl"), + "{\"type\":\"user\",\"isMeta\":true,\"message\":{\"role\":\"user\"," + + "\"content\":\"this tabs prompt\"},\"timestamp\":\"" + + OLD_TS.plusSeconds(60) + "\"}"); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "this tabs prompt"), + "a system-injected record is not evidence of a submitted prompt"); + } + + /** + * A user message can be split into several text blocks. Only the whole + * textual message is the prompt: accepting an individual block that + * matches would let a longer unrelated message look like this tab's + * prompt. + */ + @Test + void containsUserPromptSince_requiresTheWholeMessageAcrossBlocks() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeJsonl(sessionDir.resolve("s1.jsonl"), + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"continue\"}," + + "{\"type\":\"text\",\"text\":\"and run the deployment\"}]}," + + "\"timestamp\":\"" + OLD_TS.plusSeconds(60) + "\"}"); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "continue"), + "a block of a longer message is not the whole prompt"); + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "continue\nand run the deployment"), + "the whole textual message is the prompt"); + } + + /** + * Claude stores tool results as {@code type: "user"} records while it + * works. A sibling tab finishing a tool must not look like a human prompt + * submitted in this tab. + */ + @Test + void containsUserMessageSince_ignoresToolResults() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[" + + "{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\"," + + "\"content\":\"file contents\"}]},\"timestamp\":\"" + + OLD_TS.plusSeconds(60) + "\"}"); + + assertFalse(ClaudeSessionStore.containsUserMessageSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli()), + "a tool result is written by Claude, not by the human"); + } + + /** A system-injected (meta) user record is not a human prompt either. */ + @Test + void containsUserMessageSince_ignoresMetaRecords() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, "{\"type\":\"user\",\"isMeta\":true,\"message\":{\"role\":\"user\"," + + "\"content\":\"injected context\"},\"timestamp\":\"" + + OLD_TS.plusSeconds(60) + "\"}"); + + assertFalse(ClaudeSessionStore.containsUserMessageSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli())); + } + + @Test + void containsUserMessageSince_acceptsHumanText() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"a typed prompt\"}]},\"timestamp\":\"" + + OLD_TS.plusSeconds(60) + "\"}"); + + assertTrue(ClaudeSessionStore.containsUserMessageSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli())); + } + + @Test + void findSession_returnsRequestedSessionOnly() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "s1", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "s2", OLD_TS, BASE_MTIME + 1_000L); + + SavedSession s = ClaudeSessionStore.findSession(workDir, tmpDir, "s2"); + assertNotNull(s); + assertEquals("s2", s.sessionId()); + assertNull(ClaudeSessionStore.findSession(workDir, tmpDir, "missing")); + assertNull(ClaudeSessionStore.findSession(workDir, tmpDir, null)); + } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java index 8f7399cc..82433613 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java @@ -77,6 +77,148 @@ void testGetName() throws Exception { p.waitFor(); } + /** + * Verifies that every block written to the PTY reaches the input listener, + * which the controller uses to notice text typed in the terminal. + */ + @Test + void testInputListenerReceivesWrittenBytes() throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + + PtyProcess p = startCat(); + List received = new ArrayList<>(); + PtyTtyConnector c = new PtyTtyConnector(p); + c.setInputListener((bytes, sentAt) -> + received.add(new String(bytes, StandardCharsets.UTF_8))); + + c.write("first"); + c.write(new byte[]{'\r'}); + // No waitFor() here: pty4j 0.12.4 never reports the exit of a child + // whose PTY output was read through an InputStream (as the connector + // does), even though the child itself terminates. + c.close(); + + assertEquals(List.of("first", "\r"), received); + } + + /** + * The submission time handed to the input listener must be sampled by the + * connector before the bytes are forwarded, not by the listener + * itself: the child may append the prompt to its session file between the + * write and the listener call (the writing thread can be descheduled after + * the flush). A later timestamp would make the controller reject the very + * prompt it just sent when it looks for evidence of the conversation on + * screen. + */ + @Test + void testSubmissionTimeIsSampledBeforeTheBytesAreDelivered() throws Exception { + RecordingPtyProcess p = new RecordingPtyProcess(); + List times = new ArrayList<>(); + PtyTtyConnector c = new PtyTtyConnector(p, () -> { + if (p.delivered) p.sampledAfterDelivery = true; + return 1_234L; + }); + c.setInputListener((bytes, sentAt) -> times.add(sentAt)); + + c.write("first"); + + assertEquals("first", p.writtenText()); + assertEquals(List.of(1_234L), times); + assertFalse(p.sampledAfterDelivery, + "the submission time must be sampled before the bytes reach the child"); + } + + /** + * The controller writes the GUI prompt's Enter under the lock it also uses + * for the terminal recorder. A concurrent terminal write must therefore be + * forwarded and recorded as one step: it may not reach the child on one + * side of that Enter while its listener runs on the other, because the + * listener would then record bytes for a line the plugin already reset. + */ + @Test + void testWriteIsSerializedWithTheSharedInputLock() throws Exception { + RecordingPtyProcess p = new RecordingPtyProcess(); + PtyTtyConnector c = new PtyTtyConnector(p); + List received = + java.util.Collections.synchronizedList(new ArrayList<>()); + c.setInputListener((bytes, sentAt) -> + received.add(new String(bytes, StandardCharsets.UTF_8))); + Object lock = new Object(); + c.setInputLock(lock); + + java.util.concurrent.CountDownLatch done = + new java.util.concurrent.CountDownLatch(1); + Thread writer; + synchronized (lock) { + writer = new Thread(() -> { + try { + c.write("typed"); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } finally { + done.countDown(); + } + }, "connector-write-test"); + writer.start(); + + assertFalse(done.await(200, java.util.concurrent.TimeUnit.MILLISECONDS), + "the write must wait while the shared lock is held"); + assertEquals("", p.writtenText(), + "no bytes may reach the child while the lock is held"); + assertTrue(received.isEmpty(), + "the listener must not run while the lock is held"); + } + + assertTrue(done.await(2, java.util.concurrent.TimeUnit.SECONDS), + "the write must proceed once the lock is released"); + assertEquals("typed", p.writtenText()); + assertEquals(List.of("typed"), received); + } + + /** + * A PTY double that records the order of the two events this test cares + * about: the pending input reaching the child and the connector sampling + * the submission time. + */ + private static final class RecordingPtyProcess extends PtyProcess { + private final java.io.ByteArrayOutputStream written = + new java.io.ByteArrayOutputStream(); + private final InputStream output = new ByteArrayInputStream(new byte[0]); + boolean delivered; + boolean sampledAfterDelivery; + + @Override + public OutputStream getOutputStream() { + return new OutputStream() { + @Override + public void write(int b) { + delivered = true; + written.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + delivered = true; + written.write(b, off, len); + } + }; + } + + String writtenText() { + return written.toString(StandardCharsets.UTF_8); + } + + @Override public InputStream getInputStream() { return output; } + @Override public InputStream getErrorStream() { return output; } + @Override public int waitFor() { return 0; } + @Override public int exitValue() { return 0; } + @Override public boolean isAlive() { return true; } + @Override public void destroy() { } + @Override public void setWinSize(com.pty4j.WinSize winSize) { } + @Override public com.pty4j.WinSize getWinSize() { return new com.pty4j.WinSize(80, 24); } + } + /** * Verifies that the line listener receives lines with ANSI escape codes stripped. * diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java index 93722402..2479c004 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java @@ -221,6 +221,23 @@ void emptyTextDoesNotFire() throws Exception { assertNull(sentText.get(), "onSend must NOT be called when text is empty"); } + /** + * While the session is still starting, the send key must not clear the field: + * the prompt could not be delivered and would be lost. + */ + @Test + void sendUnavailableKeepsTheTypedPrompt() throws Exception { + buildPanel(tempDir.getAbsolutePath()); + SwingUtilities.invokeAndWait(() -> panel.setSendAvailable(() -> false)); + setInputText("typed while starting"); + + callDoSend(); + + assertNull(sentText.get(), "onSend must not fire while sending is unavailable"); + assertEquals("typed while starting", getInputText(), + "the typed prompt must be kept for when the session is ready"); + } + @Test void afterSendInputAreaIsCleared() throws Exception { buildPanel(tempDir.getAbsolutePath()); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabLaunchCancelTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabLaunchCancelTest.java new file mode 100644 index 00000000..481dac20 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabLaunchCancelTest.java @@ -0,0 +1,39 @@ +package io.github.nbplugins.claudecodegui.ui; + +import io.github.nbplugins.claudecodegui.model.SessionMode; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies that tearing down the chat invalidates a launch that is still being + * prepared in the background. + * + *

{@code startProcessAsync} prepares the launch identity off the EDT and then + * starts the process on the EDT, guarded by {@code startRequestSeq}. Any action + * that tears the chat down — including "Start New Session", which stops and + * clears the layout but may not schedule a replacement start yet — must bump + * that sequence, otherwise the process would be attached to the detached widget. + */ +class ClaudeSessionTabLaunchCancelTest { + + @Test + void saveAndSwitchToNewSession_invalidatesAPendingLaunch() throws Exception { + ClaudeSessionTab tab = new ClaudeSessionTab(); + long before = startRequestSeq(tab); + + // No working directory is known during a launch, so this path only tears + // the chat down and does not schedule a replacement start. + tab.onSaveAndSwitch("", SessionMode.NEW, null); + + assertTrue(startRequestSeq(tab) > before, + "tearing down the chat must cancel a launch still being prepared"); + } + + private static long startRequestSeq(ClaudeSessionTab tab) throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField("startRequestSeq"); + f.setAccessible(true); + return f.getLong(tab); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java new file mode 100644 index 00000000..e6a0c429 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -0,0 +1,240 @@ +package io.github.nbplugins.claudecodegui.ui; + +import io.github.nbplugins.claudecodegui.model.ClaudeSessionModel; +import io.github.nbplugins.claudecodegui.model.SessionMode; +import java.io.File; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies that restart actions target the project being launched while that + * launch is still being prepared. + * + *

{@code ClaudeSessionModel.getWorkingDirectory()} only changes when the + * process actually starts, so during the off-EDT preparation it still names the + * previous project. A restart requested in that window (e.g. "Start New + * Session" after Restart Advanced switched the tab from project A to B) must + * use B, not A. + */ +class ClaudeSessionTabPendingLaunchTest { + + @Test + void restartAfterAPendingLaunchUsesThatLaunchesDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-test"); + try { + File previous = tmp.resolve("project-a").toFile(); + File pending = tmp.resolve("project-b").toFile(); + AtomicReference restartedIn = new AtomicReference<>(); + + ClaudeSessionTab tab = new ClaudeSessionTab() { + @Override + protected void restartSession(File dir, String profileName, + String extraCliArgs, SessionMode mode, + String resumeId) { + restartedIn.set(dir); + } + }; + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + tab.onSaveAndSwitch("", SessionMode.NEW, null); + + assertEquals(pending, restartedIn.get(), + "a restart must target the project being launched, not the " + + "previous one still held by the model"); + } finally { + tmp.toFile().delete(); + } + } + + /** + * Closing the tab while a launch is still being prepared cancels that + * launch, but reopening the tab must restore the project it was going to + * use — not the previous one still held by the model. + */ + @Test + void closingWhileAPendingLaunchSavesThatLaunchesDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-close-test"); + try { + File previous = tmp.resolve("project-a").toFile(); + File pending = tmp.resolve("project-b").toFile(); + ClaudeSessionTab tab = new ClaudeSessionTab(); + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + tab.componentClosed(); + + assertEquals(pending.getAbsolutePath(), pathToRestore(tab), + "closing during a launch must restore the project being launched"); + } finally { + tmp.toFile().delete(); + } + } + + /** + * NetBeans persists open tabs while a launch may still be preparing; the + * saved path must be the project being launched, not the previous one. + */ + @Test + void persistingDuringAPendingLaunchStoresThatLaunchesDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-save-test"); + try { + File previous = tmp.resolve("project-a").toFile(); + File pending = tmp.resolve("project-b").toFile(); + ClaudeSessionTab tab = new ClaudeSessionTab(); + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + try (java.io.ObjectOutputStream out = new java.io.ObjectOutputStream(bos)) { + tab.writeExternal(out); + } + String serialized = new String(bos.toByteArray(), + java.nio.charset.StandardCharsets.ISO_8859_1); + + // The superclass metadata (tab name/tooltip) may still describe the + // previous project until the launch starts; the restored path is the + // project being launched. + assertTrue(serialized.contains(pending.getAbsolutePath()), + "the saved path must be the project being launched"); + } finally { + tmp.toFile().delete(); + } + } + + /** + * Opening the same project again while its launch is still being prepared + * must focus that tab instead of creating a second one: the model still + * reports the previous directory (or none) until the process starts. + */ + @Test + void aTabWithAPendingLaunchServesThatDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-lookup-test"); + try { + File previous = tmp.resolve("project-a").toFile(); + File pending = tmp.resolve("project-b").toFile(); + ClaudeSessionTab tab = new ClaudeSessionTab(); + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + assertTrue(tab.servesDirectory(pending), + "a tab whose launch is pending already serves that directory"); + assertFalse(tab.servesDirectory(previous), + "the previous project must not be treated as served any more"); + } finally { + tmp.toFile().delete(); + } + } + + /** + * File references pasted or dropped into the prompt are stored relative to + * the project the session will run in. During a pending launch the model + * still names the previous project, so the supplier must use the pending + * one — otherwise the reference points at a path inside the wrong project. + */ + @Test + void promptFileReferencesUseThePendingLaunchDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-prompt-test"); + try { + File previous = tmp.resolve("repo").toFile(); + File pending = tmp.resolve("repo").resolve("module").toFile(); + ClaudeSessionTab tab = new ClaudeSessionTab(); + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + assertEquals(pending.getAbsolutePath(), tab.currentOrPendingDirectoryPath(), + "file references must be relative to the project being launched"); + } finally { + tmp.toFile().delete(); + } + } + + /** + * A profile may set {@code CLAUDE_CONFIG_DIR} through its extra environment + * variables, which takes precedence over its storage dir. A pending launch + * must resolve the directory exactly as the process will, or the dialog + * would list one store and the started process use another. + */ + @Test + void pendingLaunchConfigDirAppliesTheProfilesEnvironmentOverride() throws Exception { + Path profiles = Files.createTempDirectory("pending-launch-config-test"); + try { + io.github.nbplugins.claudecodegui.settings.ClaudeProfile profile = + io.github.nbplugins.claudecodegui.settings.ClaudeProfile.createNamed("Work"); + assertEquals(profiles.resolve("Work"), + ClaudeSessionTab.resolveLaunchConfigDir(profile, profiles), + "a named profile uses its storage dir by default"); + + Path override = profiles.resolve("override-store"); + profile.setExtraEnvVars(java.util.Collections.singletonList( + new String[]{"CLAUDE_CONFIG_DIR", override.toString()})); + assertEquals(override, + ClaudeSessionTab.resolveLaunchConfigDir(profile, profiles), + "the environment override must win over the storage dir"); + + io.github.nbplugins.claudecodegui.settings.ClaudeProfile fallback = + io.github.nbplugins.claudecodegui.settings.ClaudeProfile.createDefault(); + Path defaultOverride = profiles.resolve("default-override-store"); + fallback.setExtraEnvVars(java.util.Collections.singletonList( + new String[]{"CLAUDE_CONFIG_DIR", defaultOverride.toString()})); + assertEquals(defaultOverride, + ClaudeSessionTab.resolveLaunchConfigDir(fallback, profiles), + "the default profile's environment override must be honored too"); + } finally { + profiles.toFile().delete(); + } + } + + /** + * The Save & Switch dialog lists conversations for the replacement + * launch, which reloads the profile from the store. If the profile's + * storage directory was changed in Options while the session was running, + * the dialog must follow the new directory — the same store the restart + * will write to — instead of the one captured when the session started. + */ + @Test + void saveAndSwitchListsTheStoreTheReplacementLaunchWillUse() throws Exception { + Path profiles = Files.createTempDirectory("dialog-config-test"); + try { + io.github.nbplugins.claudecodegui.settings.ClaudeProfile profile = + io.github.nbplugins.claudecodegui.settings.ClaudeProfile.createNamed("Work"); + Path runningDir = profiles.resolve("old-store"); + profile.setStorageDir(runningDir.toString()); + assertEquals(runningDir, + ClaudeSessionTab.dialogSessionListConfigDir(profile, profiles, runningDir), + "an unchanged profile lists the store the running session uses"); + + Path movedDir = profiles.resolve("new-store"); + profile.setStorageDir(movedDir.toString()); + assertEquals(movedDir, + ClaudeSessionTab.dialogSessionListConfigDir(profile, profiles, runningDir), + "a storage dir changed in Options must be listed by the dialog"); + } finally { + profiles.toFile().delete(); + } + } + + private static String pathToRestore(ClaudeSessionTab tab) throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField("pathToRestore"); + f.setAccessible(true); + return (String) f.get(tab); + } + + private static ClaudeSessionModel modelOf(ClaudeSessionTab tab) throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField("model"); + f.setAccessible(true); + return (ClaudeSessionModel) f.get(tab); + } + + private static void setField(ClaudeSessionTab tab, String name, Object value) + throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField(name); + f.setAccessible(true); + f.set(tab, value); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java new file mode 100644 index 00000000..bb436bb6 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java @@ -0,0 +1,76 @@ +package io.github.nbplugins.claudecodegui.ui; + +import io.github.nbplugins.claudecodegui.model.SavedSession; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that the Save & Switch dialog never pre-fills the name field with + * the raw session ID (previously {@code displayName()} did, so accepting the + * prefill stored a UUID as the session name). + */ +class ClaudeSessionTabPrefillNameTest { + + private static final Instant TS = Instant.parse("2026-10-31T15:48:30Z"); + + private static SavedSession session(String slug, String title) { + return new SavedSession("7641f6b2-e7a0-4370-8087-358d762b7b7c", TS, TS, slug, title, null); + } + + @Test + void customTitleWins() { + assertEquals("My Name", ClaudeSessionTab.prefillSessionName(session("some-slug", "My Name"))); + } + + @Test + void slugUsedWhenNoTitle() { + assertEquals("sunny-zooming-thunder", + ClaudeSessionTab.prefillSessionName(session("sunny-zooming-thunder", null))); + } + + @Test + void emptyWhenNoTitleAndNoSlug_neverSessionId() { + assertEquals("", ClaudeSessionTab.prefillSessionName(session(null, null)), + "a brand-new session with no slug must not pre-fill its UUID"); + } + + @Test + void emptyWhenBlankTitleAndNoSlug() { + assertEquals("", ClaudeSessionTab.prefillSessionName(session(null, " "))); + } + + @Test + void titleEqualToSessionIdIgnored() { + String id = "7641f6b2-e7a0-4370-8087-358d762b7b7c"; + assertEquals("", ClaudeSessionTab.prefillSessionName(session(null, id)), + "a custom title equal to the session ID is junk left by the old bug"); + } + + @Test + void nullSessionGivesEmptyString() { + assertEquals("", ClaudeSessionTab.prefillSessionName(null)); + } + + @Test + void runningSessionExcludedOnlyFromItsOwnStore() { + java.nio.file.Path running = java.nio.file.Path.of("/home/u/.claude"); + java.nio.file.Path profiles = java.nio.file.Path.of("/home/u/.claude-profiles/Work"); + assertEquals("id-1", + ClaudeSessionTab.excludedSessionId("id-1", running, running), + "the listed store is the running one: hide the running conversation"); + org.junit.jupiter.api.Assertions.assertNull( + ClaudeSessionTab.excludedSessionId("id-1", profiles, running), + "another store may hold a copy under the same ID, which must stay listed"); + org.junit.jupiter.api.Assertions.assertNull( + ClaudeSessionTab.excludedSessionId(null, running, running)); + } + + @Test + void storeComparisonNormalizesThePath() { + assertEquals("id-1", ClaudeSessionTab.excludedSessionId("id-1", + java.nio.file.Path.of("/home/u/./.claude"), + java.nio.file.Path.of("/home/u/.claude"))); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java index 8f599e29..96fcf0e1 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java @@ -1,9 +1,12 @@ package io.github.nbplugins.claudecodegui.ui; +import io.github.nbplugins.claudecodegui.model.SavedSession; import io.github.nbplugins.claudecodegui.model.SessionMode; +import io.github.nbplugins.claudecodegui.process.ClaudeSessionStore; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -13,6 +16,7 @@ class SessionModePanelTest { @TempDir Path emptyDir; @TempDir Path sessionDir; + @TempDir Path otherSessionDir; /** Creates a fake sessions directory with one .jsonl stub so hasAnySessions() returns true. */ private Path withSession(Path workingDir) throws IOException { @@ -24,10 +28,27 @@ private Path withSession(Path workingDir) throws IOException { return workingDir.resolve(".claude"); } + /** + * Creates a sessions directory with timestamped JSONL stubs so that + * {@link ClaudeSessionStore#listSessions} returns them as table rows. + */ + private Path withSessions(Path workingDir, String... sessionIds) throws IOException { + Path configDir = workingDir.resolve(".claude"); + String hash = workingDir.toAbsolutePath().toString().replace("/", "-"); + Path sessionsSubDir = configDir.resolve("projects").resolve(hash); + Files.createDirectories(sessionsSubDir); + for (String id : sessionIds) { + Files.writeString(sessionsSubDir.resolve(id + ".jsonl"), + "{\"sessionId\":\"" + id + "\",\"timestamp\":\"2026-01-01T00:00:00Z\"}\n"); + } + return configDir; + } + // ------------------------------------------------------------------------- // No-sessions: radios disabled and selection falls back to NEW // ------------------------------------------------------------------------- + @Test void noSessions_continueAndResumeDisabled() { SessionModePanel panel = new SessionModePanel(emptyDir, null, null, false); @@ -120,6 +141,104 @@ void nullWorkingDir_defaultIsNew() { assertEquals(SessionMode.NEW, panel.getSelectedMode()); } + // ------------------------------------------------------------------------- + // Selection survives session-list refreshes + // ------------------------------------------------------------------------- + + @Test + void applySessions_keepsSelectedSessionById() throws IOException { + Path configDir = withSessions(sessionDir, "session-a", "session-b"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-a"); + assertEquals("session-a", panel.getSelectedSessionId()); + + List reloaded = ClaudeSessionStore.listSessions(sessionDir, configDir); + panel.applySessions(sessionDir, configDir, null, reloaded); + + assertEquals("session-a", panel.getSelectedSessionId(), + "The selected session must survive a background refresh"); + assertTrue(panel.isSelectionValid(), + "Resume specific must stay valid while the selected session is still listed"); + } + + @Test + void reload_keepsSelectedSessionById() throws IOException { + Path configDir = withSessions(sessionDir, "session-a", "session-b"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-b"); + + panel.reload(sessionDir, configDir, null); + + assertEquals("session-b", panel.getSelectedSessionId(), + "Reloading the same directory must keep the selected session"); + assertTrue(panel.isSelectionValid()); + } + + @Test + void applySessions_dropsSelectionWhenTheSessionIsGone() throws IOException { + Path configDir = withSessions(sessionDir, "session-a"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-a"); + assertTrue(panel.isSelectionValid()); + + panel.applySessions(sessionDir, configDir, null, List.of()); + + assertNull(panel.getSelectedSessionId()); + assertFalse(panel.isSelectionValid(), + "Resume specific without a row must not be accepted by the Open button"); + } + + @Test + void applySessions_excludedSessionIsNotSelected() throws IOException { + Path configDir = withSessions(sessionDir, "session-a", "session-b"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-a"); + + List reloaded = ClaudeSessionStore.listSessions(sessionDir, configDir); + panel.applySessions(sessionDir, configDir, "session-a", reloaded); + + assertNull(panel.getSelectedSessionId(), + "The excluded (current) session must not stay selected"); + assertFalse(panel.isSelectionValid()); + } + + @Test + void applySessionsIfCurrent_appliesForTheShownStore() throws IOException { + Path configDir = withSessions(sessionDir, "session-a"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + + List reloaded = ClaudeSessionStore.listSessions(sessionDir, configDir); + assertTrue(panel.applySessionsIfCurrent(sessionDir, configDir, reloaded)); + } + + @Test + void applySessionsIfCurrent_discardsALoadForAnotherStore() throws IOException { + Path configA = withSessions(sessionDir, "session-a"); + Path configB = withSessions(otherSessionDir, "session-b"); + + SessionModePanel panel = new SessionModePanel(sessionDir, configA, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-a"); + + // The user switches to another project while a load for the old one + // is still in flight. + panel.reload(otherSessionDir, configB, null); + panel.selectSessionById("session-b"); + + List stale = ClaudeSessionStore.listSessions(sessionDir, configA); + assertFalse(panel.applySessionsIfCurrent(sessionDir, configA, stale), + "Sessions loaded for the previous selection must be discarded"); + + assertEquals("session-b", panel.getSelectedSessionId(), + "The table must still show the newly selected directory's sessions"); + assertTrue(panel.isSelectionValid()); + } + // ------------------------------------------------------------------------- // Selection validity // -------------------------------------------------------------------------