{@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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 {@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 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 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 {@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