From cab2ccdae1f060c89b76179ca2b3195b0d8d2de1 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 12:39:29 +0300 Subject: [PATCH 001/111] Fixed session name not saved to the stopped session in the Save & Switch dialog The name entered in the Save & Switch dialog could be written into the wrong session file, or lost entirely when choosing Restart Advanced. The controller now captures the session identity at launch instead of guessing it at stop time: - the effective CLAUDE_CONFIG_DIR (including profile extra env vars, otherwise ~/.claude), so named profiles write into their own store; - the session ID the running process uses: RESUME_SPECIFIC -> resume ID, CONTINUE_LAST -> newest .jsonl by mtime at launch, NEW -> the file created after launch (excluding a pre-launch snapshot of existing IDs). stopAndRename() renames exactly that ID in exactly that directory, and skips the write when the title is unchanged. The dialog prefills the name as customTitle -> slug -> empty, never the raw session ID (previously a UUID was offered for brand-new sessions and then stored as the title). The session list in the selector is refreshed right after Restart Advanced so the saved name is visible immediately. New helpers in ClaudeSessionStore: listSessionIds, findLatestSessionId, findSessionIdCreatedAfter, findSession, sessionDirForLog. ClaudeProcess exposes the resolved config dir (configDirFrom/resolveConfigDir/getConfigDir). Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + pom.xml | 2 +- .../controller/ClaudeSessionController.java | 117 ++++++++++++-- .../claudecodegui/process/ClaudeProcess.java | 48 +++++- .../process/ClaudeSessionStore.java | 137 ++++++++++++++++ .../ui/ClaudeSessionSelectorPanel.java | 23 ++- .../claudecodegui/ui/ClaudeSessionTab.java | 50 +++--- .../claudecodegui/ui/SessionModePanel.java | 2 +- .../ClaudeSessionControllerTest.java | 148 ++++++++++++++++++ .../process/ClaudeSessionStoreTest.java | 94 +++++++++++ .../ui/ClaudeSessionTabPrefillNameTest.java | 55 +++++++ 11 files changed, 629 insertions(+), 48 deletions(-) create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c68808..e720eb6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed the session name entered in the Save & Switch dialog not being saved to the session that was closed (affected named connection profiles and projects with several sessions), including when choosing Restart Advanced # 1.3.45 (2026-10-28) - Fixed edit-mode detection and switching while background agents or shells are listed under the status line # 1.3.42 (2026-10-22) diff --git a/pom.xml b/pom.xml index 9683e912..5cae4baf 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.69-SNAPSHOT + 1.3.70-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index ce2e7540..6799afbc 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -104,6 +104,22 @@ public class ClaudeSessionController { /** {@code true} once the first byte/line from the PTY has been received. */ private volatile boolean firstOutputReceived = false; + /** + * 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. */ + private volatile Set preStartSessionIds = Set.of(); + /** * Called when a hang is detected: receives (command, errorMessage). * Invoked on the EDT after the process is stopped. @@ -181,6 +197,14 @@ void simulateProcessStart(long startedAt, boolean outputReceived) { firstOutputReceived = outputReceived; } + /** Package-private for tests: sets the session identity captured at launch. */ + void simulateActiveSession(java.nio.file.Path configDir, String sessionId, + Set preStartIds) { + activeConfigDir = configDir; + activeSessionId = sessionId; + preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); + } + /** Package-private for tests: injects a stripped PTY line into the rolling buffer. */ void simulatePtyLine(String line) { synchronized (recentPtyLines) { @@ -251,11 +275,30 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr claudeProcess = new ClaudeProcess(); ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); customModelIds = profile != null ? new ArrayList<>(profile.getComboModelIds()) : List.of(); + + // Capture the session identity before launch so that stopping/renaming later + // targets the session this process will actually use (see stopAndRename). + SessionMode effectiveMode = mode != null ? mode : SessionMode.NEW; + java.nio.file.Path projectedConfigDir = + ClaudeProcess.resolveConfigDir(profile, ClaudeCodePreferences.getProfilesDir()); + preStartSessionIds = ClaudeSessionStore.listSessionIds(dir.toPath(), projectedConfigDir); + if (effectiveMode == SessionMode.RESUME_SPECIFIC + && resumeSessionId != null && !resumeSessionId.isBlank()) { + activeSessionId = resumeSessionId; + } else if (effectiveMode == SessionMode.CONTINUE_LAST) { + activeSessionId = ClaudeSessionStore.findLatestSessionId(dir.toPath(), projectedConfigDir); + } else { + activeSessionId = null; + } + PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : "", - mode != null ? mode : SessionMode.NEW, - resumeSessionId); + effectiveMode, resumeSessionId); + activeConfigDir = claudeProcess.getConfigDir(); + if (activeConfigDir == null) { + activeConfigDir = projectedConfigDir; + } processStartedAt = System.currentTimeMillis(); firstOutputReceived = false; @@ -352,6 +395,9 @@ public void stopProcess() { synchronized (recentPtyLines) { recentPtyLines.clear(); } processStartedAt = 0; firstOutputReceived = false; + activeConfigDir = null; + activeSessionId = null; + preStartSessionIds = Set.of(); model.setModelList(List.of(), -1); clearActiveChoiceMenu(); @@ -360,29 +406,68 @@ 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 captured at launch, so the rename + * works for named profiles (isolated config dir), profiles that map to + * {@code ~/.claude}, and several sessions sharing the same project. * * @param sessionName name to persist, or {@code null}/blank to skip renaming */ public void stopAndRename(String sessionName) { - java.io.File workingDir = model.getWorkingDirectory(); + java.io.File dir = model.getWorkingDirectory(); + java.nio.file.Path configDir = activeConfigDir; + String sessionId = resolveActiveSessionId(); 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()); + + if (sessionName == null || sessionName.isBlank()) return; + if (dir == null || configDir == null || sessionId == null) { + LOG.warning("Could not rename session: no active session file found in " + + ClaudeSessionStore.sessionDirForLog( + dir != null ? dir.toPath() : null, configDir) + + " (dir=" + dir + ", configDir=" + configDir + ")"); + return; + } + String name = sessionName.trim(); + try { + SavedSession current = ClaudeSessionStore.findSession(dir.toPath(), configDir, sessionId); + if (current != null && name.equals(current.customTitle())) { + LOG.fine("Session " + sessionId + " already titled '" + name + "'; nothing to do"); + return; } + ClaudeSessionStore.renameSession(dir.toPath(), configDir, sessionId, name); + LOG.fine("Renamed session " + sessionId + " to '" + name + "' in " + configDir); + } catch (Exception e) { + LOG.warning("Could not rename session " + sessionId + " in " + configDir + ": " + e.getMessage()); } } + /** + * Returns the ID of the session currently being displayed, resolving the + * file created by this run for {@link SessionMode#NEW} sessions. + * + * @return session ID, or {@code null} if it cannot be determined + */ + public String resolveActiveSessionId() { + String known = activeSessionId; + if (known != null) return known; + java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); + java.nio.file.Path configDir = activeConfigDir; + if (dir == null || configDir == null) return null; + return ClaudeSessionStore.findSessionIdCreatedAfter( + dir.toPath(), configDir, preStartSessionIds, processStartedAt); + } + + /** + * 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 // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java index d5f9d0aa..b15397f6 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java @@ -82,6 +82,12 @@ public ClaudeProcess() {} /** UUID of the active OpenAI proxy session; {@code null} when not using OpenAI proxy. */ private volatile String openAIProxyUuid; + /** + * Effective {@code CLAUDE_CONFIG_DIR} used by the running process (never + * {@code null} while a process is running). Defaults to {@code ~/.claude}. + */ + private volatile Path effectiveConfigDir; + /** * Starts a Claude CLI PTY process in the given working directory using * the Default profile (no extra env vars injected). @@ -158,6 +164,7 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl String executable = ClaudeCodePreferences.resolveClaudeExecutable(); Map env = buildEnv(profile, ClaudeCodePreferences.getProfilesDir()); + effectiveConfigDir = configDirFrom(env); // OpenAI proxy: register session and inject ANTHROPIC_BASE_URL pointing to internal proxy if (profile != null @@ -333,10 +340,7 @@ void appendSessionFlags(List cmd, String workingDir, Map } if (mode == SessionMode.CONTINUE_LAST) { - String configDirStr = env.get("CLAUDE_CONFIG_DIR"); - Path claudeConfigDir = configDirStr != null - ? Path.of(configDirStr) - : Path.of(System.getProperty("user.home"), ".claude"); + Path claudeConfigDir = configDirFrom(env); if (ClaudeSessionStore.hasAnySessions(Path.of(workingDir), claudeConfigDir)) { cmd.add("--continue"); LOG.fine("Session mode: --continue (existing session found)"); @@ -822,6 +826,42 @@ static Map buildEnv(ClaudeProfile profile, java.nio.file.Path pr return env; } + /** + * Resolves the effective {@code CLAUDE_CONFIG_DIR} from an environment map. + * + * @param env environment map used for the child process + * @return the configured directory, or {@code ~/.claude} when not overridden + */ + static java.nio.file.Path configDirFrom(Map env) { + String configDirStr = env.get("CLAUDE_CONFIG_DIR"); + return configDirStr != null && !configDirStr.isBlank() + ? java.nio.file.Path.of(configDirStr) + : java.nio.file.Path.of(System.getProperty("user.home"), ".claude"); + } + + /** + * Resolves the config dir the given profile will use once started. + * + *

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

Uses only file modification times (no parsing) — this mirrors the session + * that {@code claude --continue} resumes. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @return session ID, or {@code null} if no session file exists + */ + public static String findLatestSessionId(Path workingDir, Path claudeConfigDir) { + Path newest = newestSessionFile(sessionsDir(workingDir, claudeConfigDir), null, 0); + return newest != null ? sessionIdOf(newest) : null; + } + + /** + * Returns the ID of a session file created since a launch: the newest JSONL file + * whose ID is not in {@code excludeIds} and whose mtime is at least + * {@code minMtimeMillis}. If no such file matches the mtime, falls back to the + * newest file not in {@code excludeIds}. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param excludeIds session IDs that already existed before the launch (may be {@code null}) + * @param minMtimeMillis earliest acceptable file modification time (epoch millis) + * @return session ID, or {@code null} if no candidate exists + */ + public static String findSessionIdCreatedAfter(Path workingDir, Path claudeConfigDir, + Set excludeIds, long minMtimeMillis) { + Path dir = sessionsDir(workingDir, claudeConfigDir); + Set exclude = excludeIds != null ? excludeIds : Set.of(); + Path newest = newestSessionFile(dir, exclude, minMtimeMillis); + if (newest == null) { + newest = newestSessionFile(dir, exclude, 0); + } + return newest != null ? sessionIdOf(newest) : null; + } + + private static Path newestSessionFile(Path dir, Set excludeIds, long minMtimeMillis) { + if (!Files.isDirectory(dir)) { + return null; + } + Path newest = null; + long newestMtime = Long.MIN_VALUE; + try (Stream files = Files.list(dir)) { + for (Path file : (Iterable) files::iterator) { + String name = file.getFileName().toString(); + if (!name.endsWith(".jsonl")) continue; + String id = sessionIdOf(file); + if (excludeIds != null && excludeIds.contains(id)) continue; + long mtime; + try { + mtime = Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + continue; + } + if (mtime < minMtimeMillis) continue; + if (newest == null || mtime > newestMtime) { + newest = file; + newestMtime = mtime; + } + } + } catch (IOException e) { + LOG.fine("Could not scan sessions in " + dir + ": " + e.getMessage()); + } + return newest; + } + + private static String sessionIdOf(Path file) { + String name = file.getFileName().toString(); + return name.endsWith(".jsonl") ? name.substring(0, name.length() - 6) : name; + } + + /** + * Returns the parsed session with the given ID. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sessionId session ID (file name without {@code .jsonl}) + * @return the session, or {@code null} if the file is missing or unparseable + */ + public static SavedSession findSession(Path workingDir, Path claudeConfigDir, String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return null; + } + Path file = sessionsDir(workingDir, claudeConfigDir).resolve(sessionId + ".jsonl"); + if (!Files.exists(file)) { + return null; + } + try { + return parseSessionFile(file); + } catch (Exception e) { + LOG.fine("Could not parse session file " + file + ": " + e.getMessage()); + return null; + } + } + // ------------------------------------------------------------------------- // List // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java index 3f07ff4b..fc0b0285 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java @@ -399,14 +399,29 @@ private void onProjectSelected() { private void reloadSessionModePanel() { if (sessionModePanel != null) { - sessionModePanel.reload( - resolveDir() != null ? resolveDir().toPath() : null, - resolveClaudeConfigDir(), - null); + reloadSessionTable(); sessionModePanel.setMode(ClaudeCodePreferences.getContextMenuSessionMode()); } } + /** + * Reloads the session list of the embedded mode panel without changing the + * selected mode. Called after a session stops so a name saved by the + * Save & Switch dialog is visible immediately. + */ + public void refreshSessionList() { + if (sessionModePanel != null) { + reloadSessionTable(); + } + } + + private void reloadSessionTable() { + sessionModePanel.reload( + resolveDir() != null ? resolveDir().toPath() : null, + resolveClaudeConfigDir(), + null); + } + private File resolveDir() { Object item = pathCombo != null ? pathCombo.getSelectedItem() : null; if (item == null || item.toString().isBlank()) return null; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index f069d4a5..fb5f99dd 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -1232,28 +1232,14 @@ public void openSwitchDialog(SessionMode initialMode) { File workingDir = model.getWorkingDirectory(); 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 dialog lists/prefills the same store the running process uses. + Path claudeConfigDir = controller.getActiveConfigDir(); + String currentId = controller.resolveActiveSessionId(); + SavedSession current = currentId != null + ? ClaudeSessionStore.findSession(workingDir.toPath(), claudeConfigDir, currentId) + : null; + String currentName = prefillSessionName(current); final ClaudeSessionTab self = this; final Path configDir = claudeConfigDir; @@ -1272,6 +1258,24 @@ public void openSwitchDialog(SessionMode initialMode) { dialog.setVisible(true); } + /** + * 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 : ""; + } + public void onSaveAndSwitch(String sessionName, SessionMode mode, String resumeId) { File dir = model.getWorkingDirectory(); String profileName = selectorPanel.getSelectedProfileName(); @@ -1332,6 +1336,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..340a034e 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java @@ -372,7 +372,7 @@ private void onRename() { SavedSession session = tableModel.getSession(modelRow); if (session == null) return; - String current = session.displayName(); + String current = ClaudeSessionTab.prefillSessionName(session); String newName = JOptionPane.showInputDialog( this, "Rename session:", current); if (newName == null || newName.isBlank()) return; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7cd2b478..732f21d6 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1376,6 +1376,154 @@ void isModelCommandPendingWithBlankLine_falseWhenModelInHistory() { assertFalse(controller.isModelCommandPendingWithBlankLine(lines)); } + // ------------------------------------------------------------------------- + // stopAndRename — must target the session that was running + // ------------------------------------------------------------------------- + + @org.junit.jupiter.api.io.TempDir + java.nio.file.Path tmpDir; + + private static String hashOf(java.nio.file.Path workDir) { + return workDir.toAbsolutePath().toString().replace('/', '-'); + } + + private static java.nio.file.Path sessionDir(java.nio.file.Path configDir, + java.nio.file.Path workDir) throws java.io.IOException { + java.nio.file.Path dir = configDir.resolve("projects").resolve(hashOf(workDir)); + java.nio.file.Files.createDirectories(dir); + return dir; + } + + private static void writeSession(java.nio.file.Path dir, String id, String slug) + throws java.io.IOException { + java.nio.file.Files.writeString(dir.resolve(id + ".jsonl"), + "{\"sessionId\":\"" + id + "\",\"slug\":\"" + slug + + "\",\"timestamp\":\"2024-01-01T10:00:00Z\",\"type\":\"summary\"}\n"); + } + + private static void touch(java.nio.file.Path file) throws java.io.IOException { + java.nio.file.Files.setLastModifiedTime(file, + java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + } + + private static long countCustomTitles(java.nio.file.Path file, String value) + throws java.io.IOException { + String json = "\"customTitle\":\"" + value + "\""; + return java.nio.file.Files.readAllLines(file).stream() + .filter(line -> line.contains("\"type\":\"custom-title\"")) + .filter(line -> line.contains(json)) + .count(); + } + + @Test + void stopAndRename_writesIntoCapturedConfigDir() throws Exception { + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path profileDir = tmpDir.resolve("profiles").resolve("Work"); + java.nio.file.Path dir = sessionDir(profileDir, work); + writeSession(dir, "s1", "slug"); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(System.currentTimeMillis(), true); + controller.simulateActiveSession(profileDir, "s1", Set.of("s1")); + + controller.stopAndRename("My Session"); + + io.github.nbplugins.claudecodegui.model.SavedSession s = + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, profileDir, "s1"); + assertNotNull(s); + assertEquals("My Session", s.customTitle(), + "the title must be written into the profile's own config dir"); + } + + @Test + void stopAndRename_targetsLaunchSession_notAnotherTab() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // Another tab's session, modified more recently than our launch. + writeSession(dir, "other-tab", "other"); + touch(dir.resolve("other-tab.jsonl")); + // Snapshot taken right before our process launched. + Set before = Set.of("other-tab"); + writeSession(dir, "this-session", "this"); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(System.currentTimeMillis() - 1_000L, true); + controller.simulateActiveSession(config, null, before); + + controller.stopAndRename("Closed Session"); + + assertEquals("Closed Session", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "this-session").customTitle(), + "the session created by this run must be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "other-tab").customTitle(), + "another tab's session must not be touched"); + } + + @Test + void stopAndRename_usesResumeSessionId() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "resumed", "old"); + writeSession(dir, "newer", "newer"); + touch(dir.resolve("newer.jsonl")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(System.currentTimeMillis(), true); + controller.simulateActiveSession(config, "resumed", Set.of("resumed", "newer")); + + controller.stopAndRename("Resumed Name"); + + assertEquals("Resumed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "resumed").customTitle()); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "newer").customTitle(), + "a newer unrelated session must not be renamed"); + } + + @Test + void stopAndRename_skipsUnchangedTitle() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "s1", "slug"); + java.nio.file.Files.writeString(dir.resolve("s1.jsonl"), + "{\"type\":\"custom-title\",\"customTitle\":\"Same\",\"sessionId\":\"s1\"}\n", + java.nio.file.StandardOpenOption.APPEND); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(System.currentTimeMillis(), true); + controller.simulateActiveSession(config, "s1", Set.of("s1")); + + controller.stopAndRename("Same"); + + assertEquals(1, countCustomTitles(dir.resolve("s1.jsonl"), "Same"), + "repeated stops must not append duplicate custom-title lines"); + } + + @Test + void stopAndRename_blankName_doesNothing() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "s1", "slug"); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(System.currentTimeMillis(), true); + controller.simulateActiveSession(config, "s1", Set.of("s1")); + + controller.stopAndRename(" "); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "s1").customTitle()); + } + // ------------------------------------------------------------------------- // No-op listener base class // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 53cab19d..f48846b1 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -195,4 +195,98 @@ void renameSession_throwsWhenFileNotFound() { assertThrows(IOException.class, () -> ClaudeSessionStore.renameSession(workDir, tmpDir, "no-such-id", "Name")); } + + // ------------------------------------------------------------------------- + // Session identity helpers + // ------------------------------------------------------------------------- + + private static final java.time.Instant OLD_TS = java.time.Instant.parse("2024-01-01T10:00:00Z"); + + private void writeTimedSession(Path sessionDir, String id, java.time.Instant ts) throws IOException { + writeJsonl(sessionDir.resolve(id + ".jsonl"), + "{\"sessionId\":\"" + id + "\",\"slug\":\"slug-" + id + + "\",\"timestamp\":\"" + ts + "\",\"type\":\"summary\"}"); + } + + @Test + void listSessionIds_returnsAllJsonlStems() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "s1", OLD_TS); + writeTimedSession(sessionDir, "s2", OLD_TS); + writeJsonl(sessionDir.resolve("notes.txt"), "ignored"); + + assertEquals(java.util.Set.of("s1", "s2"), + ClaudeSessionStore.listSessionIds(workDir, tmpDir)); + } + + @Test + void listSessionIds_missingDir_returnsEmptySet() { + assertTrue(ClaudeSessionStore.listSessionIds(tmpDir.resolve("nope"), tmpDir).isEmpty()); + } + + @Test + void findLatestSessionId_usesFileMtime() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "old", OLD_TS); + writeTimedSession(sessionDir, "new", OLD_TS); + Files.setLastModifiedTime(sessionDir.resolve("new.jsonl"), + java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + + assertEquals("new", ClaudeSessionStore.findLatestSessionId(workDir, tmpDir)); + } + + @Test + void findLatestSessionId_ignoresUnparseableNewestFile() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "older", OLD_TS); + // No timestamp → parseSessionFile() returns null, but mtime says it is newest. + writeJsonl(sessionDir.resolve("broken.jsonl"), "{\"sessionId\":\"broken\"}"); + Files.setLastModifiedTime(sessionDir.resolve("broken.jsonl"), + java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + + assertEquals("broken", ClaudeSessionStore.findLatestSessionId(workDir, tmpDir), + "mtime-based lookup must not depend on JSONL content"); + } + + @Test + void findSessionIdCreatedAfter_skipsFilesPresentBeforeLaunch() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "preexisting", OLD_TS); + java.util.Set before = ClaudeSessionStore.listSessionIds(workDir, tmpDir); + writeTimedSession(sessionDir, "created", OLD_TS); + + assertEquals("created", ClaudeSessionStore.findSessionIdCreatedAfter( + workDir, tmpDir, before, 0L)); + } + + @Test + void findSessionIdCreatedAfter_prefersFileNewerThanLaunch() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "stale-new", OLD_TS); + writeTimedSession(sessionDir, "fresh", OLD_TS); + Files.setLastModifiedTime(sessionDir.resolve("fresh.jsonl"), + java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + + assertEquals("fresh", ClaudeSessionStore.findSessionIdCreatedAfter( + workDir, tmpDir, java.util.Set.of(), System.currentTimeMillis() - 5_000L)); + } + + @Test + void findSession_returnsRequestedSessionOnly() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "s1", OLD_TS); + writeTimedSession(sessionDir, "s2", OLD_TS); + + SavedSession s = ClaudeSessionStore.findSession(workDir, tmpDir, "s2"); + assertNotNull(s); + assertEquals("s2", s.sessionId()); + assertNull(ClaudeSessionStore.findSession(workDir, tmpDir, "missing")); + assertNull(ClaudeSessionStore.findSession(workDir, tmpDir, null)); + } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java new file mode 100644 index 00000000..fdde6628 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java @@ -0,0 +1,55 @@ +package io.github.nbplugins.claudecodegui.ui; + +import io.github.nbplugins.claudecodegui.model.SavedSession; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that the Save & Switch dialog never pre-fills the name field with + * the raw session ID (previously {@code displayName()} did, so accepting the + * prefill stored a UUID as the session name). + */ +class ClaudeSessionTabPrefillNameTest { + + private static final Instant TS = Instant.parse("2026-10-31T15:48:30Z"); + + private static SavedSession session(String slug, String title) { + return new SavedSession("7641f6b2-e7a0-4370-8087-358d762b7b7c", TS, TS, slug, title, null); + } + + @Test + void customTitleWins() { + assertEquals("My Name", ClaudeSessionTab.prefillSessionName(session("some-slug", "My Name"))); + } + + @Test + void slugUsedWhenNoTitle() { + assertEquals("sunny-zooming-thunder", + ClaudeSessionTab.prefillSessionName(session("sunny-zooming-thunder", null))); + } + + @Test + void emptyWhenNoTitleAndNoSlug_neverSessionId() { + assertEquals("", ClaudeSessionTab.prefillSessionName(session(null, null)), + "a brand-new session with no slug must not pre-fill its UUID"); + } + + @Test + void emptyWhenBlankTitleAndNoSlug() { + assertEquals("", ClaudeSessionTab.prefillSessionName(session(null, " "))); + } + + @Test + void titleEqualToSessionIdIgnored() { + String id = "7641f6b2-e7a0-4370-8087-358d762b7b7c"; + assertEquals("", ClaudeSessionTab.prefillSessionName(session(null, id)), + "a custom title equal to the session ID is junk left by the old bug"); + } + + @Test + void nullSessionGivesEmptyString() { + assertEquals("", ClaudeSessionTab.prefillSessionName(null)); + } +} From 06a850c4a4487bf743cf78e47d96b4400bfbaec0 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 13:33:29 +0300 Subject: [PATCH 002/111] Fixed renaming the wrong conversation after /resume inside a running session The session identity was fixed at launch, so a conversation switch made with Claude's own /resume picker left stopAndRename() renaming the session that was open before the switch. The controller now re-resolves the active session whenever its ID is needed: the file the running process wrote last (mtime at or after the launch) is the active conversation. Tabs sharing a project and config store publish and claim their own sessions first, so another tab's file is never mistaken for the one being closed. Session closures release all claims; only live controllers count as owners. Also made the mtime-based test fixtures deterministic by setting explicit file modification times instead of relying on wall-clock writes, which could tie within one millisecond and make the newest-file lookup nondeterministic. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- .../controller/ClaudeSessionController.java | 140 +++++++++++++++++- .../process/ClaudeSessionStore.java | 22 +++ .../ClaudeSessionControllerTest.java | 84 +++++++++-- .../process/ClaudeSessionStoreTest.java | 70 ++++++--- 5 files changed, 273 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e720eb6e..9f00c45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Fixed the session name entered in the Save & Switch dialog not being saved to the session that was closed (affected named connection profiles and projects with several sessions), including when choosing Restart Advanced +- Fixed the session name entered in the Save & Switch dialog not being saved to the session that was closed (affected named connection profiles, projects with several sessions, and conversations switched with /resume inside the terminal), including when choosing Restart Advanced # 1.3.45 (2026-10-28) - Fixed edit-mode detection and switching while background agents or shells are listed under the status line # 1.3.42 (2026-10-22) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 6799afbc..9ade36e9 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -23,8 +23,10 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import java.util.logging.Logger; import java.util.regex.Matcher; @@ -104,6 +106,18 @@ public class ClaudeSessionController { /** {@code true} once the first byte/line from the PTY has been received. */ private volatile boolean firstOutputReceived = false; + /** + * Controllers whose PTY process is currently running. Tabs that share a + * project / config store use this set to publish (and claim) their own + * session before another tab decides which file to rename. + */ + private static final Set LIVE_SESSIONS = + ConcurrentHashMap.newKeySet(); + + /** Session ID → live controllers running that session. */ + private static final Map> SESSION_CLAIMS = + new ConcurrentHashMap<>(); + /** * Effective Claude config dir of the running session, captured at launch. * Used so that renaming targets the same store the process writes to. @@ -205,6 +219,17 @@ void simulateActiveSession(java.nio.file.Path configDir, String sessionId, preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); } + /** + * Package-private for tests: marks this controller as a live session, so + * that other controllers treat {@code sessionId} as owned by another tab. + */ + void simulateLiveSession(java.nio.file.Path configDir, String sessionId, + Set preStartIds) { + simulateActiveSession(configDir, sessionId, preStartIds); + LIVE_SESSIONS.add(this); + claimSession(sessionId); + } + /** Package-private for tests: injects a stripped PTY line into the rolling buffer. */ void simulatePtyLine(String line) { synchronized (recentPtyLines) { @@ -302,6 +327,9 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr processStartedAt = System.currentTimeMillis(); firstOutputReceived = false; + LIVE_SESSIONS.add(this); + claimSession(activeSessionId); + connector = new PtyTtyConnector(process); connector.setSessionTag(tag); screenContentDetector.setSessionTag(tag); @@ -395,6 +423,8 @@ public void stopProcess() { synchronized (recentPtyLines) { recentPtyLines.clear(); } processStartedAt = 0; firstOutputReceived = false; + LIVE_SESSIONS.remove(this); + releaseAllClaims(); activeConfigDir = null; activeSessionId = null; preStartSessionIds = Set.of(); @@ -409,9 +439,10 @@ public void stopProcess() { * 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 captured at launch, so the rename + *

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}, and several sessions sharing the same project. + * {@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 */ @@ -447,16 +478,111 @@ public void stopAndRename(String sessionName) { * Returns the ID of the session currently being displayed, resolving the * file created by this run for {@link SessionMode#NEW} sessions. * + *

The identity is not fixed at launch: Claude can switch conversations + * inside the running process (its own {@code /resume} picker), in which case + * the file written most recently belongs to the new session. Other live tabs + * sharing the same store are asked to publish — and claim — their own + * sessions first, so their files are never mistaken for this one's. + * * @return session ID, or {@code null} if it cannot be determined */ public String resolveActiveSessionId() { - String known = activeSessionId; - if (known != null) return known; java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); java.nio.file.Path configDir = activeConfigDir; - if (dir == null || configDir == null) return null; - return ClaudeSessionStore.findSessionIdCreatedAfter( - dir.toPath(), configDir, preStartSessionIds, processStartedAt); + if (dir == null || configDir == null) return activeSessionId; + + publishOtherSessions(dir, configDir); + refreshBaseIdentity(); + + if (processStartedAt > 0) { + // Claude writes only to the active conversation, so the session file + // it touched last is the one being displayed — this catches session + // switches made inside the TUI without a process restart. + String newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, processStartedAt, claimedByOthers()); + if (newest != null && !newest.equals(activeSessionId)) { + LOG.fine("Active session switched to " + newest + " in " + configDir); + String previous = activeSessionId; + activeSessionId = newest; + claimSession(newest); + releaseClaim(previous); + } + } + return activeSessionId; + } + + /** + * Asks other live tabs on the same project and config store to resolve and + * claim their sessions. Two passes so that a tab which first learns its own + * session also has it advertised to the others before they decide. + */ + private void publishOtherSessions(java.io.File dir, java.nio.file.Path configDir) { + for (int pass = 0; pass < 2; pass++) { + for (ClaudeSessionController other : LIVE_SESSIONS) { + if (other != this && configDir.equals(other.activeConfigDir) + && dir.equals(other.workingDir)) { + other.refreshBaseIdentity(); + } + } + } + } + + /** + * Resolves the session ID known at launch when it is still unknown: for + * {@link SessionMode#NEW} this is the file created after the launch that is + * not in the pre-launch snapshot and not claimed by another live tab. + * Claims the resolved ID so other tabs can see it. + */ + private synchronized void refreshBaseIdentity() { + if (activeSessionId == null) { + java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); + java.nio.file.Path configDir = activeConfigDir; + if (dir != null && configDir != null) { + Set exclude = new HashSet<>(preStartSessionIds); + exclude.addAll(claimedByOthers()); + activeSessionId = ClaudeSessionStore.findSessionIdCreatedAfter( + dir.toPath(), configDir, exclude, processStartedAt); + } + } + claimSession(activeSessionId); + } + + private void claimSession(String sessionId) { + if (sessionId != null) { + SESSION_CLAIMS + .computeIfAbsent(sessionId, k -> ConcurrentHashMap.newKeySet()) + .add(this); + } + } + + private void releaseClaim(String sessionId) { + if (sessionId == null) return; + SESSION_CLAIMS.computeIfPresent(sessionId, (id, owners) -> { + owners.remove(this); + return owners.isEmpty() ? null : owners; + }); + } + + /** Removes all claims held by this controller (called when the process stops). */ + private void releaseAllClaims() { + for (Set owners : SESSION_CLAIMS.values()) { + owners.remove(this); + } + SESSION_CLAIMS.entrySet().removeIf(e -> e.getValue().isEmpty()); + } + + /** Session IDs claimed by other live controllers. */ + private Set claimedByOthers() { + Set ids = new HashSet<>(); + SESSION_CLAIMS.forEach((id, owners) -> { + for (ClaudeSessionController owner : owners) { + if (owner != this && LIVE_SESSIONS.contains(owner)) { + ids.add(id); + return; + } + } + }); + return ids; } /** diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 4f62eed2..9e76f6be 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -172,6 +172,28 @@ public static String findSessionIdCreatedAfter(Path workingDir, Path claudeConfi return newest != null ? sessionIdOf(newest) : null; } + /** + * Returns the ID of the session file modified most recently, considering only + * files whose mtime is at least {@code sinceMillis} and whose ID is not in + * {@code excludeIds}. + * + *

Unlike {@link #findSessionIdCreatedAfter} there is no fallback to older + * files: this is used to detect which conversation a running process is + * currently writing after it may have switched sessions in-process. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sinceMillis earliest acceptable file modification time (epoch millis) + * @param excludeIds session IDs to ignore (e.g. owned by other tabs); may be {@code null} + * @return session ID, or {@code null} if no file was modified since {@code sinceMillis} + */ + public static String findLatestSessionIdWrittenSince(Path workingDir, Path claudeConfigDir, + long sinceMillis, Set excludeIds) { + Path newest = newestSessionFile( + sessionsDir(workingDir, claudeConfigDir), excludeIds, sinceMillis); + return newest != null ? sessionIdOf(newest) : null; + } + private static Path newestSessionFile(Path dir, Set excludeIds, long minMtimeMillis) { if (!Files.isDirectory(dir)) { return null; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 732f21d6..ee581456 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3,6 +3,7 @@ import io.github.nbplugins.claudecodegui.model.ClaudeSessionModel; import io.github.nbplugins.claudecodegui.model.EditMode; import io.github.nbplugins.claudecodegui.model.SessionLifecycle; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -34,6 +35,12 @@ void setUp() { controller = new ClaudeSessionController(model, Collections::emptyList); } + @AfterEach + void tearDown() { + // Drops the controller from the live-session / claim registries. + controller.stopProcess(); + } + // ------------------------------------------------------------------------- // onClaudeIdle // ------------------------------------------------------------------------- @@ -1383,6 +1390,9 @@ void isModelCommandPendingWithBlankLine_falseWhenModelInHistory() { @org.junit.jupiter.api.io.TempDir java.nio.file.Path tmpDir; + /** Fixed launch timestamp; tests set file mtimes relative to it. */ + private static final long START_MS = 1_700_000_000_000L; + private static String hashOf(java.nio.file.Path workDir) { return workDir.toAbsolutePath().toString().replace('/', '-'); } @@ -1401,9 +1411,9 @@ private static void writeSession(java.nio.file.Path dir, String id, String slug) + "\",\"timestamp\":\"2024-01-01T10:00:00Z\",\"type\":\"summary\"}\n"); } - private static void touch(java.nio.file.Path file) throws java.io.IOException { + private static void setMtime(java.nio.file.Path file, long millis) throws java.io.IOException { java.nio.file.Files.setLastModifiedTime(file, - java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + java.nio.file.attribute.FileTime.fromMillis(millis)); } private static long countCustomTitles(java.nio.file.Path file, String value) @@ -1421,9 +1431,10 @@ void stopAndRename_writesIntoCapturedConfigDir() throws Exception { java.nio.file.Path profileDir = tmpDir.resolve("profiles").resolve("Work"); java.nio.file.Path dir = sessionDir(profileDir, work); writeSession(dir, "s1", "slug"); + setMtime(dir.resolve("s1.jsonl"), START_MS - 1_000L); model.setWorkingDirectory(work.toFile()); - controller.simulateProcessStart(System.currentTimeMillis(), true); + controller.simulateProcessStart(START_MS, true); controller.simulateActiveSession(profileDir, "s1", Set.of("s1")); controller.stopAndRename("My Session"); @@ -1442,26 +1453,64 @@ void stopAndRename_targetsLaunchSession_notAnotherTab() throws Exception { java.nio.file.Path work = tmpDir.resolve("project"); java.nio.file.Path dir = sessionDir(config, work); - // Another tab's session, modified more recently than our launch. + // Another tab is running "other-tab" and wrote it after our launch. writeSession(dir, "other-tab", "other"); - touch(dir.resolve("other-tab.jsonl")); + setMtime(dir.resolve("other-tab.jsonl"), START_MS + 500L); + ClaudeSessionController otherTab = + new ClaudeSessionController(new ClaudeSessionModel(), Collections::emptyList); + otherTab.simulateProcessStart(START_MS - 500L, true); + otherTab.simulateLiveSession(config, "other-tab", Set.of("other-tab")); + // Snapshot taken right before our process launched. Set before = Set.of("other-tab"); writeSession(dir, "this-session", "this"); + setMtime(dir.resolve("this-session.jsonl"), START_MS + 1_000L); model.setWorkingDirectory(work.toFile()); - controller.simulateProcessStart(System.currentTimeMillis() - 1_000L, true); + controller.simulateProcessStart(START_MS, true); controller.simulateActiveSession(config, null, before); - controller.stopAndRename("Closed Session"); + try { + controller.stopAndRename("Closed Session"); + + assertEquals("Closed Session", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "this-session").customTitle(), + "the session created by this run must be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "other-tab").customTitle(), + "another tab's session must not be touched"); + } finally { + otherTab.stopProcess(); + } + } + + @Test + void stopAndRename_followsSessionSwitchedInProcess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "first", "first"); + setMtime(dir.resolve("first.jsonl"), START_MS - 5_000L); + writeSession(dir, "second", "second"); + // The TUI's /resume picker switched conversations after the launch: + // the second file is the one the process writes. + setMtime(dir.resolve("second.jsonl"), START_MS + 2_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "first", Set.of("first", "second")); + + controller.stopAndRename("Switched Name"); - assertEquals("Closed Session", + assertEquals("Switched Name", io.github.nbplugins.claudecodegui.process.ClaudeSessionStore - .findSession(work, config, "this-session").customTitle(), - "the session created by this run must be renamed"); + .findSession(work, config, "second").customTitle(), + "the name must go to the conversation the process switched to"); assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore - .findSession(work, config, "other-tab").customTitle(), - "another tab's session must not be touched"); + .findSession(work, config, "first").customTitle(), + "the conversation left by the switch must not be renamed"); } @Test @@ -1470,11 +1519,12 @@ void stopAndRename_usesResumeSessionId() throws Exception { java.nio.file.Path work = tmpDir.resolve("project"); java.nio.file.Path dir = sessionDir(config, work); writeSession(dir, "resumed", "old"); + setMtime(dir.resolve("resumed.jsonl"), START_MS - 5_000L); writeSession(dir, "newer", "newer"); - touch(dir.resolve("newer.jsonl")); + setMtime(dir.resolve("newer.jsonl"), START_MS - 1_000L); model.setWorkingDirectory(work.toFile()); - controller.simulateProcessStart(System.currentTimeMillis(), true); + controller.simulateProcessStart(START_MS, true); controller.simulateActiveSession(config, "resumed", Set.of("resumed", "newer")); controller.stopAndRename("Resumed Name"); @@ -1496,9 +1546,10 @@ void stopAndRename_skipsUnchangedTitle() throws Exception { java.nio.file.Files.writeString(dir.resolve("s1.jsonl"), "{\"type\":\"custom-title\",\"customTitle\":\"Same\",\"sessionId\":\"s1\"}\n", java.nio.file.StandardOpenOption.APPEND); + setMtime(dir.resolve("s1.jsonl"), START_MS - 1_000L); model.setWorkingDirectory(work.toFile()); - controller.simulateProcessStart(System.currentTimeMillis(), true); + controller.simulateProcessStart(START_MS, true); controller.simulateActiveSession(config, "s1", Set.of("s1")); controller.stopAndRename("Same"); @@ -1513,9 +1564,10 @@ void stopAndRename_blankName_doesNothing() throws Exception { java.nio.file.Path work = tmpDir.resolve("project"); java.nio.file.Path dir = sessionDir(config, work); writeSession(dir, "s1", "slug"); + setMtime(dir.resolve("s1.jsonl"), START_MS - 1_000L); model.setWorkingDirectory(work.toFile()); - controller.simulateProcessStart(System.currentTimeMillis(), true); + controller.simulateProcessStart(START_MS, true); controller.simulateActiveSession(config, "s1", Set.of("s1")); controller.stopAndRename(" "); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index f48846b1..229b1e85 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -202,18 +202,25 @@ void renameSession_throwsWhenFileNotFound() { private static final java.time.Instant OLD_TS = java.time.Instant.parse("2024-01-01T10:00:00Z"); - private void writeTimedSession(Path sessionDir, String id, java.time.Instant ts) throws IOException { - writeJsonl(sessionDir.resolve(id + ".jsonl"), + /** Fixed filesystem mtime base; tests derive deterministic mtimes from it. */ + private static final long BASE_MTIME = 1_700_000_000_000L; + + private void writeTimedSession(Path sessionDir, String id, java.time.Instant ts, + long mtimeMillis) throws IOException { + Path file = sessionDir.resolve(id + ".jsonl"); + writeJsonl(file, "{\"sessionId\":\"" + id + "\",\"slug\":\"slug-" + id + "\",\"timestamp\":\"" + ts + "\",\"type\":\"summary\"}"); + Files.setLastModifiedTime(file, + java.nio.file.attribute.FileTime.fromMillis(mtimeMillis)); } @Test void listSessionIds_returnsAllJsonlStems() throws IOException { Path workDir = tmpDir.resolve("myproject"); Path sessionDir = createSessionDir(tmpDir, workDir); - writeTimedSession(sessionDir, "s1", OLD_TS); - writeTimedSession(sessionDir, "s2", OLD_TS); + writeTimedSession(sessionDir, "s1", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "s2", OLD_TS, BASE_MTIME); writeJsonl(sessionDir.resolve("notes.txt"), "ignored"); assertEquals(java.util.Set.of("s1", "s2"), @@ -229,10 +236,8 @@ void listSessionIds_missingDir_returnsEmptySet() { void findLatestSessionId_usesFileMtime() throws IOException { Path workDir = tmpDir.resolve("myproject"); Path sessionDir = createSessionDir(tmpDir, workDir); - writeTimedSession(sessionDir, "old", OLD_TS); - writeTimedSession(sessionDir, "new", OLD_TS); - Files.setLastModifiedTime(sessionDir.resolve("new.jsonl"), - java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + writeTimedSession(sessionDir, "old", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "new", OLD_TS, BASE_MTIME + 1_000L); assertEquals("new", ClaudeSessionStore.findLatestSessionId(workDir, tmpDir)); } @@ -241,11 +246,12 @@ void findLatestSessionId_usesFileMtime() throws IOException { void findLatestSessionId_ignoresUnparseableNewestFile() throws IOException { Path workDir = tmpDir.resolve("myproject"); Path sessionDir = createSessionDir(tmpDir, workDir); - writeTimedSession(sessionDir, "older", OLD_TS); + writeTimedSession(sessionDir, "older", OLD_TS, BASE_MTIME); // No timestamp → parseSessionFile() returns null, but mtime says it is newest. - writeJsonl(sessionDir.resolve("broken.jsonl"), "{\"sessionId\":\"broken\"}"); - Files.setLastModifiedTime(sessionDir.resolve("broken.jsonl"), - java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + Path broken = sessionDir.resolve("broken.jsonl"); + writeJsonl(broken, "{\"sessionId\":\"broken\"}"); + Files.setLastModifiedTime(broken, + java.nio.file.attribute.FileTime.fromMillis(BASE_MTIME + 1_000L)); assertEquals("broken", ClaudeSessionStore.findLatestSessionId(workDir, tmpDir), "mtime-based lookup must not depend on JSONL content"); @@ -255,9 +261,9 @@ void findLatestSessionId_ignoresUnparseableNewestFile() throws IOException { void findSessionIdCreatedAfter_skipsFilesPresentBeforeLaunch() throws IOException { Path workDir = tmpDir.resolve("myproject"); Path sessionDir = createSessionDir(tmpDir, workDir); - writeTimedSession(sessionDir, "preexisting", OLD_TS); + writeTimedSession(sessionDir, "preexisting", OLD_TS, BASE_MTIME); java.util.Set before = ClaudeSessionStore.listSessionIds(workDir, tmpDir); - writeTimedSession(sessionDir, "created", OLD_TS); + writeTimedSession(sessionDir, "created", OLD_TS, BASE_MTIME + 1_000L); assertEquals("created", ClaudeSessionStore.findSessionIdCreatedAfter( workDir, tmpDir, before, 0L)); @@ -267,21 +273,43 @@ void findSessionIdCreatedAfter_skipsFilesPresentBeforeLaunch() throws IOExceptio void findSessionIdCreatedAfter_prefersFileNewerThanLaunch() throws IOException { Path workDir = tmpDir.resolve("myproject"); Path sessionDir = createSessionDir(tmpDir, workDir); - writeTimedSession(sessionDir, "stale-new", OLD_TS); - writeTimedSession(sessionDir, "fresh", OLD_TS); - Files.setLastModifiedTime(sessionDir.resolve("fresh.jsonl"), - java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + writeTimedSession(sessionDir, "stale-new", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "fresh", OLD_TS, BASE_MTIME + 2_000L); assertEquals("fresh", ClaudeSessionStore.findSessionIdCreatedAfter( - workDir, tmpDir, java.util.Set.of(), System.currentTimeMillis() - 5_000L)); + workDir, tmpDir, java.util.Set.of(), BASE_MTIME + 1_000L)); + } + + @Test + void findLatestSessionIdWrittenSince_returnsNullWhenNothingNewer() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "old", OLD_TS, BASE_MTIME); + + assertNull(ClaudeSessionStore.findLatestSessionIdWrittenSince( + workDir, tmpDir, BASE_MTIME + 1_000L, java.util.Set.of())); + } + + @Test + void findLatestSessionIdWrittenSince_picksNewestAndSkipsExcluded() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "mine", OLD_TS, BASE_MTIME + 1_000L); + writeTimedSession(sessionDir, "other-tab", OLD_TS, BASE_MTIME + 5_000L); + + // "other-tab" is newer but belongs to another tab. + assertEquals("mine", ClaudeSessionStore.findLatestSessionIdWrittenSince( + workDir, tmpDir, BASE_MTIME, java.util.Set.of("other-tab"))); + assertEquals("other-tab", ClaudeSessionStore.findLatestSessionIdWrittenSince( + workDir, tmpDir, BASE_MTIME, java.util.Set.of())); } @Test void findSession_returnsRequestedSessionOnly() throws IOException { Path workDir = tmpDir.resolve("myproject"); Path sessionDir = createSessionDir(tmpDir, workDir); - writeTimedSession(sessionDir, "s1", OLD_TS); - writeTimedSession(sessionDir, "s2", OLD_TS); + writeTimedSession(sessionDir, "s1", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "s2", OLD_TS, BASE_MTIME + 1_000L); SavedSession s = ClaudeSessionStore.findSession(workDir, tmpDir, "s2"); assertNotNull(s); From d8416cf420f1052d051d4da60df55957f0ab5251 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 13:48:54 +0300 Subject: [PATCH 003/111] Fixed sibling tabs claiming each other's session file Resolving one tab's session could make an unresolved sibling claim the newer file on its behalf, leaving the resolving tab with no ID and its entered name dropped on stop. New sessions now bind to the process that created them: when a prompt is submitted, a short background poll claims the file written since that prompt (or since launch for input typed directly into the terminal), and the 500 ms screen poll does the same. A tab never resolves another tab's identity: the two-pass publish that forced sibling lookups is removed, so only a tab's own process-specific evidence claims a session. The strict lookup excludes sessions already claimed by other live tabs and ignores files older than the launch. Regression test newSessionsClaimTheirOwnFileBeforeSiblingGuesses reproduces the reported ordering (A unresolved, B resolves first) and fails against the previous implementation. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 85 +++++++++++++++---- .../ClaudeSessionControllerTest.java | 37 ++++++++ 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 9ade36e9..b0922627 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -134,6 +134,13 @@ public class ClaudeSessionController { /** Session IDs that already existed when the current session was launched. */ private volatile Set preStartSessionIds = Set.of(); + /** + * Time (epoch millis) a prompt was last submitted from the plugin. Used to + * attribute the session file created by a new process to this tab promptly, + * before any sibling tab could guess and claim it. + */ + private volatile long promptSubmittedAt = 0; + /** * Called when a hang is detected: receives (command, errorMessage). * Invoked on the EDT after the process is stopped. @@ -226,6 +233,7 @@ void simulateActiveSession(java.nio.file.Path configDir, String sessionId, void simulateLiveSession(java.nio.file.Path configDir, String sessionId, Set preStartIds) { simulateActiveSession(configDir, sessionId, preStartIds); + workingDir = model.getWorkingDirectory(); LIVE_SESSIONS.add(this); claimSession(sessionId); } @@ -428,6 +436,7 @@ public void stopProcess() { activeConfigDir = null; activeSessionId = null; preStartSessionIds = Set.of(); + promptSubmittedAt = 0; model.setModelList(List.of(), -1); clearActiveChoiceMenu(); @@ -491,7 +500,9 @@ public String resolveActiveSessionId() { java.nio.file.Path configDir = activeConfigDir; if (dir == null || configDir == null) return activeSessionId; - publishOtherSessions(dir, configDir); + // Other live tabs publish their own sessions when they learn them (at + // launch for continue/resume, at the first prompt for new sessions); + // never guess on another tab's behalf, or its file could be claimed here. refreshBaseIdentity(); if (processStartedAt > 0) { @@ -511,22 +522,6 @@ public String resolveActiveSessionId() { return activeSessionId; } - /** - * Asks other live tabs on the same project and config store to resolve and - * claim their sessions. Two passes so that a tab which first learns its own - * session also has it advertised to the others before they decide. - */ - private void publishOtherSessions(java.io.File dir, java.nio.file.Path configDir) { - for (int pass = 0; pass < 2; pass++) { - for (ClaudeSessionController other : LIVE_SESSIONS) { - if (other != this && configDir.equals(other.activeConfigDir) - && dir.equals(other.workingDir)) { - other.refreshBaseIdentity(); - } - } - } - } - /** * Resolves the session ID known at launch when it is still unknown: for * {@link SessionMode#NEW} this is the file created after the launch that is @@ -534,6 +529,9 @@ private void publishOtherSessions(java.io.File dir, java.nio.file.Path configDir * Claims the resolved ID so other tabs can see it. */ private synchronized void refreshBaseIdentity() { + if (activeSessionId == null) { + resolveNewSessionIdentity(); + } if (activeSessionId == null) { java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); java.nio.file.Path configDir = activeConfigDir; @@ -547,6 +545,52 @@ private synchronized void refreshBaseIdentity() { claimSession(activeSessionId); } + /** + * Attributes the session file written since this tab submitted its prompt + * (or since launch, when the prompt was typed directly into the terminal) + * to this tab and claims it, so no sibling tab can claim it first. + * + * @return {@code true} if the identity is now known + */ + synchronized boolean resolveNewSessionIdentity() { + if (activeSessionId != null) return true; + java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); + java.nio.file.Path configDir = activeConfigDir; + long since = Math.max(processStartedAt, promptSubmittedAt); + if (dir == null || configDir == null || since <= 0) return false; + Set exclude = new HashSet<>(preStartSessionIds); + exclude.addAll(claimedByOthers()); + String id = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, since, exclude); + if (id == null) return false; + activeSessionId = id; + claimSession(id); + LOG.fine("Resolved session " + id + " for " + dir + " (written since " + since + ")"); + return true; + } + + /** + * Starts a short background poll that claims a new session as soon as its + * file appears after the first prompt. Runs briefly: once the identity is + * known the poll exits immediately on the next tick. + */ + private void startNewSessionResolver() { + if (activeSessionId != null) return; + Thread t = new Thread(() -> { + for (int i = 0; i < 10; i++) { + try { + Thread.sleep(300); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (resolveNewSessionIdentity()) return; + } + }, "session-id-resolve"); + t.setDaemon(true); + t.start(); + } + private void claimSession(String sessionId) { if (sessionId != null) { SESSION_CLAIMS @@ -614,6 +658,8 @@ public void sendPrompt(String text) { if (!text.isBlank()) { model.addPromptToHistory(text); } + promptSubmittedAt = System.currentTimeMillis(); + startNewSessionResolver(); Thread t = new Thread(() -> { try { @@ -1447,6 +1493,11 @@ void discoverModels() { * */ void pollScreenState() { + // Claim a new session's file as soon as it appears, so that other tabs + // never mistake it for their own. No-op once the identity is known. + if (activeSessionId == null) { + resolveNewSessionIdentity(); + } List lines = screenLines.get(); // Plan name diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index ee581456..133b386d 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1513,6 +1513,43 @@ void stopAndRename_followsSessionSwitchedInProcess() throws Exception { "the conversation left by the switch must not be renamed"); } + @Test + void newSessionsClaimTheirOwnFileBeforeSiblingGuesses() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // Tab A (NEW) wrote its session after its first prompt, but has not + // opened Save & Switch yet, so its identity is still unresolved. + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS + 1_000L); + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + // Tab B (NEW) launches later and writes its own, newer session. + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 5_000L); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 4_000L, true); + tabB.simulateLiveSession(config, null, Set.of("a")); + + try { + // Resolving B must not make A claim B's file on its behalf. + assertEquals("b", tabB.resolveActiveSessionId(), + "tab B must resolve to its own session"); + assertEquals("a", controller.resolveActiveSessionId(), + "tab A must keep the session it created"); + assertEquals("b", tabB.resolveActiveSessionId(), + "tab B must still resolve to its own session"); + } finally { + tabB.stopProcess(); + } + } + @Test void stopAndRename_usesResumeSessionId() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From e30f26d5ce07ed650cceda9ce5b648d828b830bb Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 13:50:49 +0300 Subject: [PATCH 004/111] Reduced idle session-identity scans on the EDT An unresolved NEW tab scanned its session directory on every 500 ms screen poll even before the first prompt. Without a plugin-submitted prompt there is no attribution anchor, so that fallback now runs every ten ticks (~5 s); with a prompt it still runs on every tick. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index b0922627..ab9e9698 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -141,6 +141,9 @@ public class ClaudeSessionController { */ private volatile long promptSubmittedAt = 0; + /** 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. @@ -437,6 +440,7 @@ public void stopProcess() { activeSessionId = null; preStartSessionIds = Set.of(); promptSubmittedAt = 0; + identityPollTicks = 0; model.setModelList(List.of(), -1); clearActiveChoiceMenu(); @@ -1495,7 +1499,10 @@ void discoverModels() { void pollScreenState() { // Claim a new session's file as soon as it appears, so that other tabs // never mistake it for their own. No-op once the identity is known. - if (activeSessionId == null) { + // Without a plugin-submitted prompt there is no attribution anchor, so + // the directory scan runs every ~5 s instead of on every tick. + if (activeSessionId == null + && (promptSubmittedAt > 0 || ++identityPollTicks % 10 == 0)) { resolveNewSessionIdentity(); } List lines = screenLines.get(); From fdd31b8f5779e116a91323246307273f1a7f9971 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 14:10:08 +0300 Subject: [PATCH 005/111] Fixed stale session claims after in-process switches and terminal exits The session-identity poll skipped tabs whose ID was already known, so a conversation switched inside the TUI left the previous session claimed by that tab forever: a sibling switching to it excluded its own current file and resolved to the abandoned one. The PTY waiter also never released the claims when Claude quit or crashed with the tab still open, keeping an exited tab an owner indefinitely. Unified the resolution into one `refreshSessionIdentity()` that re-samples the newest file written since the launch (or the last plugin prompt), switches the identity, claims the new session and releases the old claim; it now runs for known and unknown identities alike (throttled in the screen poll, every 300 ms for 3 s after a prompt, and synchronously in `stopAndRename`). The pre-launch snapshot still excludes pre-existing files while a NEW session is unresolved. The waiter thread reports the process exit so `LIVE_SESSIONS` and the claims are released, and `claimedByOthers()` now also ignores owners whose process is not alive. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 156 +++++++++--------- .../ClaudeSessionControllerTest.java | 74 +++++++++ 2 files changed, 154 insertions(+), 76 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index ab9e9698..0f26d61a 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -82,7 +82,7 @@ public class ClaudeSessionController { private final ScreenContentDetector screenContentDetector = new ScreenContentDetector(); - private ClaudeProcess claudeProcess; + private volatile ClaudeProcess claudeProcess; private PtyTtyConnector connector; private java.io.File workingDir; private volatile JediTermWidget terminalWidget; @@ -379,12 +379,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. + ClaudeProcess ownerProcess = claudeProcess; Thread waiter = new Thread(() -> { try { process.waitFor(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); + } finally { + onProcessExited(ownerProcess); } }, "claude-waiter"); waiter.setDaemon(true); @@ -500,86 +504,58 @@ public void stopAndRename(String sessionName) { * @return session ID, or {@code null} if it cannot be determined */ public String resolveActiveSessionId() { + refreshSessionIdentity(); + return activeSessionId; + } + + /** + * Re-resolves the session the running process is writing and keeps the + * cross-tab claim registry in sync: when Claude switches conversations + * (in-process {@code /resume}), the claim on the previous session is + * released so a sibling tab can take it, and the new one is claimed. + * + *

The identity is the newest session file written since the launch (or + * since the last prompt submitted from the plugin) that is not owned by + * another live tab. When no such file exists yet — e.g. a continue/resume + * launch that has not written anything — the identity captured at launch is + * kept. + * + * @return {@code true} if an identity is known + */ + synchronized boolean refreshSessionIdentity() { java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); java.nio.file.Path configDir = activeConfigDir; - if (dir == null || configDir == null) return activeSessionId; - - // Other live tabs publish their own sessions when they learn them (at - // launch for continue/resume, at the first prompt for new sessions); - // never guess on another tab's behalf, or its file could be claimed here. - refreshBaseIdentity(); - - if (processStartedAt > 0) { - // Claude writes only to the active conversation, so the session file - // it touched last is the one being displayed — this catches session - // switches made inside the TUI without a process restart. + long since = Math.max(processStartedAt, promptSubmittedAt); + if (dir != null && configDir != null && since > 0) { + Set exclude = new HashSet<>(claimedByOthers()); + if (activeSessionId == null) { + // A session that existed before the launch cannot be the new one + // created by this process, even if something else touched it. + exclude.addAll(preStartSessionIds); + } String newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( - dir.toPath(), configDir, processStartedAt, claimedByOthers()); + dir.toPath(), configDir, since, exclude); if (newest != null && !newest.equals(activeSessionId)) { - LOG.fine("Active session switched to " + newest + " in " + configDir); String previous = activeSessionId; activeSessionId = newest; claimSession(newest); releaseClaim(previous); - } - } - return activeSessionId; - } - - /** - * Resolves the session ID known at launch when it is still unknown: for - * {@link SessionMode#NEW} this is the file created after the launch that is - * not in the pre-launch snapshot and not claimed by another live tab. - * Claims the resolved ID so other tabs can see it. - */ - private synchronized void refreshBaseIdentity() { - if (activeSessionId == null) { - resolveNewSessionIdentity(); - } - if (activeSessionId == null) { - java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); - java.nio.file.Path configDir = activeConfigDir; - if (dir != null && configDir != null) { - Set exclude = new HashSet<>(preStartSessionIds); - exclude.addAll(claimedByOthers()); - activeSessionId = ClaudeSessionStore.findSessionIdCreatedAfter( - dir.toPath(), configDir, exclude, processStartedAt); + LOG.fine("Session of " + dir + " is now " + newest + + (previous != null ? " (was " + previous + ")" : "")); } } claimSession(activeSessionId); + return activeSessionId != null; } /** - * Attributes the session file written since this tab submitted its prompt - * (or since launch, when the prompt was typed directly into the terminal) - * to this tab and claims it, so no sibling tab can claim it first. - * - * @return {@code true} if the identity is now known + * Samples the session identity for a few seconds after a prompt, so a new + * session file is claimed as soon as it appears and an in-process switch is + * noticed without waiting for the screen poll. */ - synchronized boolean resolveNewSessionIdentity() { - if (activeSessionId != null) return true; - java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); + private void startIdentityResolver() { java.nio.file.Path configDir = activeConfigDir; - long since = Math.max(processStartedAt, promptSubmittedAt); - if (dir == null || configDir == null || since <= 0) return false; - Set exclude = new HashSet<>(preStartSessionIds); - exclude.addAll(claimedByOthers()); - String id = ClaudeSessionStore.findLatestSessionIdWrittenSince( - dir.toPath(), configDir, since, exclude); - if (id == null) return false; - activeSessionId = id; - claimSession(id); - LOG.fine("Resolved session " + id + " for " + dir + " (written since " + since + ")"); - return true; - } - - /** - * Starts a short background poll that claims a new session as soon as its - * file appears after the first prompt. Runs briefly: once the identity is - * known the poll exits immediately on the next tick. - */ - private void startNewSessionResolver() { - if (activeSessionId != null) return; + if (configDir == null) return; Thread t = new Thread(() -> { for (int i = 0; i < 10; i++) { try { @@ -588,7 +564,7 @@ private void startNewSessionResolver() { Thread.currentThread().interrupt(); return; } - if (resolveNewSessionIdentity()) return; + refreshSessionIdentity(); } }, "session-id-resolve"); t.setDaemon(true); @@ -624,7 +600,7 @@ private Set claimedByOthers() { Set ids = new HashSet<>(); SESSION_CLAIMS.forEach((id, owners) -> { for (ClaudeSessionController owner : owners) { - if (owner != this && LIVE_SESSIONS.contains(owner)) { + if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { ids.add(id); return; } @@ -633,6 +609,33 @@ private Set claimedByOthers() { return ids; } + /** + * Returns {@code true} while this controller's PTY is running. Controllers + * without a process (test seams) count as live. + */ + private boolean isProcessLive() { + ClaudeProcess cp = claudeProcess; + return cp == null || cp.isRunning(); + } + + /** + * Releases live-session bookkeeping when the PTY exits on its own (Claude + * quit or crashed while the tab stayed open). Ignored when a newer process + * has already replaced it. + */ + private void onProcessExited(ClaudeProcess owner) { + synchronized (this) { + if (claudeProcess != owner) return; + } + LIVE_SESSIONS.remove(this); + releaseAllClaims(); + } + + /** Package-private for tests: simulates the PTY exiting without stopProcess(). */ + void simulateProcessExit() { + onProcessExited(claudeProcess); + } + /** * Returns the effective config dir of the running session. * @@ -663,7 +666,7 @@ public void sendPrompt(String text) { model.addPromptToHistory(text); } promptSubmittedAt = System.currentTimeMillis(); - startNewSessionResolver(); + startIdentityResolver(); Thread t = new Thread(() -> { try { @@ -1497,13 +1500,14 @@ void discoverModels() { * */ void pollScreenState() { - // Claim a new session's file as soon as it appears, so that other tabs - // never mistake it for their own. No-op once the identity is known. - // Without a plugin-submitted prompt there is no attribution anchor, so - // the directory scan runs every ~5 s instead of on every tick. - if (activeSessionId == null - && (promptSubmittedAt > 0 || ++identityPollTicks % 10 == 0)) { - resolveNewSessionIdentity(); + // 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. A still-unknown identity + // right after a prompt is retried on every tick; otherwise the directory + // scan runs every ~5 s. + boolean unresolvedAfterPrompt = activeSessionId == null && promptSubmittedAt > 0; + if (unresolvedAfterPrompt || ++identityPollTicks % 10 == 0) { + refreshSessionIdentity(); } List lines = screenLines.get(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 133b386d..2c2561d8 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1550,6 +1550,80 @@ void newSessionsClaimTheirOwnFileBeforeSiblingGuesses() throws Exception { } } + @Test + void inProcessSwitch_releasesTheClaimOnThePreviousSession() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // Tab A starts on "a"; tab B starts on "b" after it. + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 1_000L); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 2_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // Tab A switches to "c" inside the TUI; the screen poll notices it + // after ~5 s and must release the claim on the abandoned "a". + writeSession(dir, "c", "c"); + setMtime(dir.resolve("c.jsonl"), START_MS + 3_000L); + for (int i = 0; i < 10; i++) { + controller.pollScreenState(); + } + + // Tab B switches to the conversation A left behind and prompts, so + // "a" is the newest file it has written. + setMtime(dir.resolve("a.jsonl"), START_MS + 6_000L); + assertEquals("a", tabB.resolveActiveSessionId(), + "the claim released by A's switch must let B resume that session"); + } finally { + tabB.stopProcess(); + } + } + + @Test + void processExit_releasesTheSessionClaims() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // Tab A runs "a" and its PTY exits from inside the terminal. + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + controller.simulateProcessExit(); + + // Tab B starts later and switches to the now-free "a". + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 4_000L); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 3_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + setMtime(dir.resolve("a.jsonl"), START_MS + 6_000L); + assertEquals("a", tabB.resolveActiveSessionId(), + "an exited process must not keep claiming its session"); + } finally { + tabB.stopProcess(); + } + } + @Test void stopAndRename_usesResumeSessionId() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From 86d40bd9a82844c8c92c60a007c7f6812674eb21 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 14:11:52 +0300 Subject: [PATCH 006/111] Updated the session-identity Javadoc to describe the claim registry Co-Authored-By: Claude Code --- .../claudecodegui/controller/ClaudeSessionController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 0f26d61a..6f40b2c6 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -497,9 +497,9 @@ public void stopAndRename(String sessionName) { * *

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. Other live tabs - * sharing the same store are asked to publish — and claim — their own - * sessions first, so their files are never mistaken for this one's. + * 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 */ From f9f8cfcaf266b63f2a4ac62f0171495bdf61f30a Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 14:29:33 +0300 Subject: [PATCH 007/111] Fixed unresolved NEW sessions resuming a saved conversation and exit races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NEW tab can resume an existing conversation before it creates a JSONL of its own. The pre-launch snapshot marked that file ineligible forever, so the resumed conversation was rejected on every scan and `stopAndRename()` silently skipped the entered name. The snapshot is now only applied while the plugin has not submitted a prompt: after a prompt, anything written since it belongs to this tab, which also covers the resumed file. The exit callback of the PTY waiter raced with process replacement during Save & Switch: it passed the owner check, was descheduled, and after the restart removed the new run from LIVE_SESSIONS and wiped its claims. Exit cleanup is now generation-based — each start gets a new generation, and the check and cleanup happen under the same lock that replaces the process, so a late callback from a stopped run is a no-op. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 82 ++++++++++++++----- .../ClaudeSessionControllerTest.java | 66 +++++++++++++++ 2 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 6f40b2c6..83a2e614 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -118,6 +118,14 @@ public class ClaudeSessionController { private static final Map> SESSION_CLAIMS = new ConcurrentHashMap<>(); + /** + * Guards {@link #claudeProcess} replacement against a late exit callback: + * every started process gets a new {@link #processGeneration}, and the + * callback only releases ownership when its generation is still current. + */ + private final Object processOwnershipLock = new Object(); + private long processGeneration; + /** * Effective Claude config dir of the running session, captured at launch. * Used so that renaming targets the same store the process writes to. @@ -131,7 +139,12 @@ public class ClaudeSessionController { */ private volatile String activeSessionId; - /** Session IDs that already existed when the current session was launched. */ + /** + * Session IDs that already existed when the current session was launched. + * They are only ineligible for an unresolved {@code NEW} session and only + * before the plugin has submitted a prompt (see + * {@link #refreshSessionIdentity()}). + */ private volatile Set preStartSessionIds = Set.of(); /** @@ -229,6 +242,11 @@ void simulateActiveSession(java.nio.file.Path configDir, String sessionId, preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); } + /** Package-private for tests: records a prompt submitted from the plugin. */ + void simulatePromptSubmittedAt(long millis) { + promptSubmittedAt = millis; + } + /** * Package-private for tests: marks this controller as a live session, so * that other controllers treat {@code sessionId} as owned by another tab. @@ -308,7 +326,11 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr JediTermWidget widget) throws IOException { String tag = "[" + dir.getName() + "] "; workingDir = dir; - claudeProcess = new ClaudeProcess(); + long generation; + synchronized (processOwnershipLock) { + claudeProcess = new ClaudeProcess(); + generation = ++processGeneration; + } ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); customModelIds = profile != null ? new ArrayList<>(profile.getComboModelIds()) : List.of(); @@ -381,14 +403,14 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr // Waiter thread: releases the session claims as soon as the process exits, // even when it exits from inside the terminal without an explicit stop. - ClaudeProcess ownerProcess = claudeProcess; + long exitedGeneration = generation; Thread waiter = new Thread(() -> { try { process.waitFor(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } finally { - onProcessExited(ownerProcess); + onProcessExited(exitedGeneration); } }, "claude-waiter"); waiter.setDaemon(true); @@ -424,11 +446,15 @@ public void stopProcess() { if (connector != null) { connector.setLineListener(null); } - if (claudeProcess != null) { - claudeProcess.stop(); + ClaudeProcess processToStop = claudeProcess; + if (processToStop != null) { + processToStop.stop(); } connector = null; - claudeProcess = null; + synchronized (processOwnershipLock) { + claudeProcess = null; + processGeneration++; + } terminalWidget = null; modelComboPopulated = false; modelDiscoveryInProgress = false; @@ -528,9 +554,13 @@ synchronized boolean refreshSessionIdentity() { long since = Math.max(processStartedAt, promptSubmittedAt); if (dir != null && configDir != null && since > 0) { Set exclude = new HashSet<>(claimedByOthers()); - if (activeSessionId == null) { - // A session that existed before the launch cannot be the new one - // created by this process, even if something else touched it. + if (activeSessionId == null && promptSubmittedAt <= 0) { + // Without a prompt of our own there is nothing that proves a + // write belongs to this tab, so a file that existed before the + // launch stays ineligible: it may have been touched by another + // Claude process. After a prompt, anything written since it is + // ours — including a saved conversation resumed from the TUI + // before this session created a file of its own. exclude.addAll(preStartSessionIds); } String newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( @@ -619,21 +649,35 @@ private boolean isProcessLive() { } /** - * Releases live-session bookkeeping when the PTY exits on its own (Claude - * quit or crashed while the tab stayed open). Ignored when a newer process - * has already replaced it. + * Releases live-session bookkeeping when the PTY of {@code generation} exits + * on its own (Claude quit or crashed while the tab stayed open). The check + * and the cleanup are atomic with process replacement, so a late callback + * from a process that has already been stopped and replaced by a restart + * cannot wipe the new run's ownership. */ - private void onProcessExited(ClaudeProcess owner) { - synchronized (this) { - if (claudeProcess != owner) return; + private void onProcessExited(long generation) { + synchronized (processOwnershipLock) { + if (generation != processGeneration) return; + LIVE_SESSIONS.remove(this); + releaseAllClaims(); } - LIVE_SESSIONS.remove(this); - releaseAllClaims(); } /** Package-private for tests: simulates the PTY exiting without stopProcess(). */ void simulateProcessExit() { - onProcessExited(claudeProcess); + simulateProcessExitAt(simulateProcessGeneration()); + } + + /** Package-private for tests: the ownership generation of the current run. */ + long simulateProcessGeneration() { + synchronized (processOwnershipLock) { + return processGeneration; + } + } + + /** Package-private for tests: replays the exit callback of an older run. */ + void simulateProcessExitAt(long generation) { + onProcessExited(generation); } /** diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 2c2561d8..f838e712 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1624,6 +1624,72 @@ void processExit_releasesTheSessionClaims() throws Exception { } } + @Test + void newSessionResumingSavedConversation_resolvesAfterPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // The saved conversation exists before the NEW tab is launched. + writeSession(dir, "saved", "saved"); + setMtime(dir.resolve("saved.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, null, Set.of("saved")); + + // The TUI's /resume picker switches to the saved conversation, and a + // prompt from the plugin rewrites it. + controller.simulatePromptSubmittedAt(START_MS + 2_000L); + setMtime(dir.resolve("saved.jsonl"), START_MS + 3_000L); + + controller.stopAndRename("Resumed Name"); + + assertEquals("Resumed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "saved").customTitle(), + "a saved conversation resumed before the first JSONL of the " + + "new session must still be renamable"); + } + + @Test + void staleExitCallback_doesNotReleaseTheReplacementRun() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // First run of this tab. + writeSession(dir, "old", "old"); + setMtime(dir.resolve("old.jsonl"), START_MS - 5_000L); + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "old", Set.of()); + long oldGeneration = controller.simulateProcessGeneration(); + + // Save & Switch restarts the tab before the old waiter reports its exit. + controller.stopProcess(); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 4_000L); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "b", Set.of()); + controller.simulateProcessExitAt(oldGeneration); + + // A sibling started later must still see "b" as owned by this tab. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 3_000L, true); + tabB.simulateLiveSession(config, null, Set.of("old")); + try { + assertNull(tabB.resolveActiveSessionId(), + "a late exit callback of the replaced process must not " + + "release the new run's claim"); + } finally { + tabB.stopProcess(); + } + } + @Test void stopAndRename_usesResumeSessionId() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From d0a6e505dee09d477d88fb3e40fe847778fbcfde Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 14:50:22 +0300 Subject: [PATCH 008/111] Fixed identity scans outliving a process restart and moved them off the EDT A background identity scan captured the working dir, config dir and launch time of one process run but published its result blindly: when Save & Switch replaced the process while the scan was in flight, the old run could set `activeSessionId` to an ID from the session store it was scanning, and later resolutions preserved it because the resumed file predated the new launch. Capturing the ownership generation with the scan inputs and re-checking it under `processOwnershipLock` at publish time discards results of a replaced run; the fields are also captured under the same lock so a scan sees a consistent snapshot. The periodic scan enumerated the whole session store from the Swing EDT (every 5 s per tab, every 500 ms while a NEW identity was unresolved after a prompt), freezing the UI on large histories, and the synchronized method could make the EDT wait behind a background resolver. The scan now runs on a shared single-thread worker queued from the screen poll, with at most one scan per controller in flight; the per-prompt resolver thread is replaced by an immediate queued scan plus the faster cadence while unresolved. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 148 +++++++++++++----- .../ClaudeSessionControllerTest.java | 32 +++- 2 files changed, 137 insertions(+), 43 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 83a2e614..d73fe3ac 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -118,6 +118,17 @@ public class ClaudeSessionController { private static final Map> SESSION_CLAIMS = new ConcurrentHashMap<>(); + /** + * Worker for the periodic session-store scans, shared by all controllers so + * that the Swing EDT never enumerates the session history. + */ + private static final java.util.concurrent.ExecutorService IDENTITY_SCANNER = + java.util.concurrent.Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "session-id-scan"); + t.setDaemon(true); + return t; + }); + /** * Guards {@link #claudeProcess} replacement against a late exit callback: * every started process gets a new {@link #processGeneration}, and the @@ -126,6 +137,10 @@ public class ClaudeSessionController { private final Object processOwnershipLock = new Object(); private long processGeneration; + /** 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. @@ -242,6 +257,11 @@ void simulateActiveSession(java.nio.file.Path configDir, String sessionId, preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); } + /** Package-private for tests: one tick of the periodic identity refresh. */ + void simulateIdentityTick() { + refreshSessionIdentityIfDue(); + } + /** Package-private for tests: records a prompt submitted from the plugin. */ void simulatePromptSubmittedAt(long millis) { promptSubmittedAt = millis; @@ -546,25 +566,56 @@ public String resolveActiveSessionId() { * 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 */ - synchronized boolean refreshSessionIdentity() { - java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); - java.nio.file.Path configDir = activeConfigDir; - long since = Math.max(processStartedAt, promptSubmittedAt); - if (dir != null && configDir != null && since > 0) { - Set exclude = new HashSet<>(claimedByOthers()); - if (activeSessionId == null && promptSubmittedAt <= 0) { - // Without a prompt of our own there is nothing that proves a - // write belongs to this tab, so a file that existed before the - // launch stays ineligible: it may have been touched by another - // Claude process. After a prompt, anything written since it is - // ours — including a saved conversation resumed from the TUI - // before this session created a file of its own. - exclude.addAll(preStartSessionIds); + boolean refreshSessionIdentity() { + long generation; + java.io.File dir; + java.nio.file.Path configDir; + long since; + Set exclude = new HashSet<>(); + synchronized (processOwnershipLock) { + generation = processGeneration; + dir = workingDir != null ? workingDir : model.getWorkingDirectory(); + configDir = activeConfigDir; + since = Math.max(processStartedAt, promptSubmittedAt); + if (dir != null && configDir != null && since > 0) { + exclude.addAll(claimedByOthers()); + if (activeSessionId == null && promptSubmittedAt <= 0) { + // Without a prompt of our own there is nothing that proves a + // write belongs to this tab, so a file that existed before + // the launch stays ineligible: it may have been touched by + // another Claude process. After a prompt, anything written + // since it is ours — including a saved conversation resumed + // from the TUI before this session created a file of its own. + exclude.addAll(preStartSessionIds); + } } - String newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( + } + String newest = null; + if (dir != null && configDir != null && since > 0) { + newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since, exclude); + } + return publishIdentityScanResult(generation, newest, dir); + } + + /** + * Publishes a scan result, unless the process was stopped or restarted while + * the scan ran — in that case the result describes a store the current run + * does not use and must not overwrite its identity. + */ + private boolean publishIdentityScanResult(long generation, String newest, + java.io.File dir) { + synchronized (processOwnershipLock) { + if (generation != processGeneration) { + return activeSessionId != null; + } if (newest != null && !newest.equals(activeSessionId)) { String previous = activeSessionId; activeSessionId = newest; @@ -573,32 +624,49 @@ synchronized boolean refreshSessionIdentity() { LOG.fine("Session of " + dir + " is now " + newest + (previous != null ? " (was " + previous + ")" : "")); } + claimSession(activeSessionId); + return activeSessionId != null; } - claimSession(activeSessionId); - return activeSessionId != null; + } + + /** Package-private for tests: publishes a result scanned for an older process run. */ + boolean simulatePublishStaleScanResult(long generation, String newest) { + return publishIdentityScanResult(generation, newest, null); } /** - * Samples the session identity for a few seconds after a prompt, so a new - * session file is claimed as soon as it appears and an in-process switch is - * noticed without waiting for the screen poll. + * Runs the periodic identity refresh when it is due: on every call while a + * {@code NEW} session is still unknown after a prompt, otherwise every + * tenth call (~5 s at the 500 ms poll cadence). Call from a worker thread — + * it touches the session directory. */ - private void startIdentityResolver() { - java.nio.file.Path configDir = activeConfigDir; - if (configDir == null) return; - Thread t = new Thread(() -> { - for (int i = 0; i < 10; i++) { + void refreshSessionIdentityIfDue() { + boolean unresolvedAfterPrompt = activeSessionId == null && promptSubmittedAt > 0; + if (unresolvedAfterPrompt || ++identityPollTicks % 10 == 0) { + refreshSessionIdentity(); + } + } + + /** + * Queues one periodic identity refresh on the shared worker, so that the + * screen poll never enumerates the session store on the EDT. At most one + * scan per controller is in flight at a time. + */ + private void scheduleIdentityRefresh() { + if (!identityScanPending.compareAndSet(false, true)) return; + try { + IDENTITY_SCANNER.execute(() -> { try { - Thread.sleep(300); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; + refreshSessionIdentityIfDue(); + } catch (RuntimeException e) { + LOG.fine("Session identity scan failed: " + e); + } finally { + identityScanPending.set(false); } - refreshSessionIdentity(); - } - }, "session-id-resolve"); - t.setDaemon(true); - t.start(); + }); + } catch (java.util.concurrent.RejectedExecutionException e) { + identityScanPending.set(false); + } } private void claimSession(String sessionId) { @@ -710,7 +778,7 @@ public void sendPrompt(String text) { model.addPromptToHistory(text); } promptSubmittedAt = System.currentTimeMillis(); - startIdentityResolver(); + scheduleIdentityRefresh(); Thread t = new Thread(() -> { try { @@ -1546,13 +1614,9 @@ 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. A still-unknown identity - // right after a prompt is retried on every tick; otherwise the directory - // scan runs every ~5 s. - boolean unresolvedAfterPrompt = activeSessionId == null && promptSubmittedAt > 0; - if (unresolvedAfterPrompt || ++identityPollTicks % 10 == 0) { - refreshSessionIdentity(); - } + // made inside the TUI is noticed by every tab. The scan enumerates the + // session store, so it runs on a worker instead of this (EDT) tick. + scheduleIdentityRefresh(); List lines = screenLines.get(); // Plan name diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index f838e712..f118c083 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1578,7 +1578,7 @@ void inProcessSwitch_releasesTheClaimOnThePreviousSession() throws Exception { writeSession(dir, "c", "c"); setMtime(dir.resolve("c.jsonl"), START_MS + 3_000L); for (int i = 0; i < 10; i++) { - controller.pollScreenState(); + controller.simulateIdentityTick(); } // Tab B switches to the conversation A left behind and prompts, so @@ -1690,6 +1690,36 @@ void staleExitCallback_doesNotReleaseTheReplacementRun() throws Exception { } } + @Test + void scanStartedBeforeRestart_doesNotPublishAfterReplacement() throws Exception { + java.nio.file.Path storeA = tmpDir.resolve("configA"); + java.nio.file.Path storeB = tmpDir.resolve("configB"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dirA = sessionDir(storeA, work); + java.nio.file.Path dirB = sessionDir(storeB, work); + writeSession(dirA, "old", "old"); + setMtime(dirA.resolve("old.jsonl"), START_MS + 2_000L); + writeSession(dirB, "new", "new"); + setMtime(dirB.resolve("new.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(storeA, "old", Set.of("old")); + long staleGeneration = controller.simulateProcessGeneration(); + + // Save & Switch restarts the tab into another profile / store before + // the background scan finishes. + controller.stopProcess(); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateActiveSession(storeB, "new", Set.of("new")); + + controller.simulatePublishStaleScanResult(staleGeneration, "old"); + + assertEquals("new", controller.resolveActiveSessionId(), + "a scan started before the restart must not overwrite the " + + "identity of the replaced run"); + } + @Test void stopAndRename_usesResumeSessionId() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From 421bffc1afcc88975a7786f3e5837166c540d3ee Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 15:09:27 +0300 Subject: [PATCH 009/111] Fixed out-of-order identity scans and store-scoped session claims Scans of one process run can overlap: the synchronous dialog/stop path and the background worker both scan. A slow worker that selected session A could publish after a newer scan had already published B and undo B's identity and claim, because the single-flight flag only serialized the scheduled scans. Every scan now reserves a monotonic sequence number under the ownership lock, and a result is published only if no scan that started later has published before it. Claims were keyed by the bare session ID, so a session file copied between the default store and an isolated profile made a live tab in one store hide the other store's file: a tab switching to the copy kept its previous identity and renamed the wrong session. Claims are now keyed by (config dir, project dir, session ID), and the scan exclusion only considers claims from the store being scanned. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 113 ++++++++++++++---- .../ClaudeSessionControllerTest.java | 67 +++++++++++ 2 files changed, 155 insertions(+), 25 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index d73fe3ac..f66e7823 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -114,8 +114,17 @@ public class ClaudeSessionController { private static final Set LIVE_SESSIONS = ConcurrentHashMap.newKeySet(); - /** Session ID → live controllers running that session. */ - private static final Map> SESSION_CLAIMS = + /** + * A session file, identified by its store: the same session ID can exist in + * several config dirs / projects (e.g. a JSONL copied between stores), and + * a claim must only hide the file it actually belongs to. + */ + private record SessionKey(java.nio.file.Path configDir, + java.nio.file.Path projectDir, + String sessionId) { } + + /** Session file → live controllers running exactly that file. */ + private static final Map> SESSION_CLAIMS = new ConcurrentHashMap<>(); /** @@ -137,6 +146,14 @@ public class ClaudeSessionController { private final Object processOwnershipLock = new Object(); private 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(); @@ -276,7 +293,7 @@ void simulateLiveSession(java.nio.file.Path configDir, String sessionId, simulateActiveSession(configDir, sessionId, preStartIds); workingDir = model.getWorkingDirectory(); LIVE_SESSIONS.add(this); - claimSession(sessionId); + claimSession(workingDir, configDir, sessionId); } /** Package-private for tests: injects a stripped PTY line into the rolling buffer. */ @@ -381,7 +398,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr firstOutputReceived = false; LIVE_SESSIONS.add(this); - claimSession(activeSessionId); + claimSession(dir, activeConfigDir, activeSessionId); connector = new PtyTtyConnector(process); connector.setSessionTag(tag); @@ -575,17 +592,19 @@ public String resolveActiveSessionId() { */ boolean refreshSessionIdentity() { long generation; + long scanId; java.io.File dir; java.nio.file.Path configDir; long since; Set exclude = new HashSet<>(); synchronized (processOwnershipLock) { generation = processGeneration; + scanId = ++scanSequence; dir = workingDir != null ? workingDir : model.getWorkingDirectory(); configDir = activeConfigDir; since = Math.max(processStartedAt, promptSubmittedAt); if (dir != null && configDir != null && since > 0) { - exclude.addAll(claimedByOthers()); + exclude.addAll(claimedByOthers(dir, configDir)); if (activeSessionId == null && promptSubmittedAt <= 0) { // Without a prompt of our own there is nothing that proves a // write belongs to this tab, so a file that existed before @@ -602,36 +621,57 @@ boolean refreshSessionIdentity() { newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since, exclude); } - return publishIdentityScanResult(generation, newest, dir); + return publishIdentityScanResult(generation, scanId, dir, configDir, newest); } /** - * Publishes a scan result, unless the process was stopped or restarted while - * the scan ran — in that case the result describes a store the current run - * does not use and must not overwrite its identity. + * Publishes a scan result, unless it was superseded: a scan started before a + * process restart describes a store the current run does not use, and a scan + * started before another one that already published must not undo the newer + * result (the synchronous dialog path and the background worker can scan + * concurrently within the same process run). */ - private boolean publishIdentityScanResult(long generation, String newest, - java.io.File dir) { + private boolean publishIdentityScanResult(long generation, long scanId, + java.io.File dir, + java.nio.file.Path configDir, + String newest) { synchronized (processOwnershipLock) { if (generation != processGeneration) { return activeSessionId != null; } + if (scanId < lastPublishedScanId) { + return activeSessionId != null; + } + lastPublishedScanId = scanId; if (newest != null && !newest.equals(activeSessionId)) { String previous = activeSessionId; activeSessionId = newest; - claimSession(newest); - releaseClaim(previous); + claimSession(dir, configDir, newest); + releaseClaim(dir, configDir, previous); LOG.fine("Session of " + dir + " is now " + newest + (previous != null ? " (was " + previous + ")" : "")); } - claimSession(activeSessionId); + claimSession(dir, configDir, activeSessionId); return activeSessionId != null; } } + /** Package-private for tests: reserves a scan slot and returns its sequence. */ + long simulateBeginIdentityScan() { + synchronized (processOwnershipLock) { + return ++scanSequence; + } + } + /** Package-private for tests: publishes a result scanned for an older process run. */ boolean simulatePublishStaleScanResult(long generation, String newest) { - return publishIdentityScanResult(generation, newest, null); + return publishIdentityScanResult(generation, simulateBeginIdentityScan(), + null, activeConfigDir, newest); + } + + /** Package-private for tests: publishes a scan result with explicit ordering. */ + boolean simulatePublishScanResult(long generation, long scanId, String newest) { + return publishIdentityScanResult(generation, scanId, null, activeConfigDir, newest); } /** @@ -669,17 +709,28 @@ private void scheduleIdentityRefresh() { } } - private void claimSession(String sessionId) { - if (sessionId != null) { + private static SessionKey keyFor(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + if (dir == null || configDir == null || sessionId == null) return null; + return new SessionKey(configDir.toAbsolutePath().normalize(), + dir.toPath().toAbsolutePath().normalize(), sessionId); + } + + private void claimSession(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + SessionKey key = keyFor(dir, configDir, sessionId); + if (key != null) { SESSION_CLAIMS - .computeIfAbsent(sessionId, k -> ConcurrentHashMap.newKeySet()) + .computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()) .add(this); } } - private void releaseClaim(String sessionId) { - if (sessionId == null) return; - SESSION_CLAIMS.computeIfPresent(sessionId, (id, owners) -> { + private void releaseClaim(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + SessionKey key = keyFor(dir, configDir, sessionId); + if (key == null) return; + SESSION_CLAIMS.computeIfPresent(key, (k, owners) -> { owners.remove(this); return owners.isEmpty() ? null : owners; }); @@ -693,13 +744,25 @@ private void releaseAllClaims() { SESSION_CLAIMS.entrySet().removeIf(e -> e.getValue().isEmpty()); } - /** Session IDs claimed by other live controllers. */ - private Set claimedByOthers() { + /** + * Session IDs claimed by other live controllers in the same store. + * A session ID is not unique across config dirs (a JSONL can be copied + * between the default and an isolated profile), so a claim from another + * store must not hide this store's file. + */ + private Set claimedByOthers(java.io.File dir, java.nio.file.Path configDir) { Set ids = new HashSet<>(); - SESSION_CLAIMS.forEach((id, owners) -> { + if (dir == null || configDir == null) return ids; + java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); + java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); + SESSION_CLAIMS.forEach((key, owners) -> { + if (!key.configDir().equals(wantedConfig) + || !key.projectDir().equals(wantedProject)) { + return; + } for (ClaudeSessionController owner : owners) { if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { - ids.add(id); + ids.add(key.sessionId()); return; } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index f118c083..47e789f0 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1720,6 +1720,73 @@ void scanStartedBeforeRestart_doesNotPublishAfterReplacement() throws Exception + "identity of the replaced run"); } + @Test + void olderScan_doesNotOverwriteANewerScanResult() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS + 1_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 2_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, null, Set.of()); + + // A slow worker scan started before the synchronous one, but finishes + // (and publishes) after it. + long slowScan = controller.simulateBeginIdentityScan(); + long fastScan = controller.simulateBeginIdentityScan(); + controller.simulatePublishScanResult( + controller.simulateProcessGeneration(), fastScan, "b"); + controller.simulatePublishScanResult( + controller.simulateProcessGeneration(), slowScan, "a"); + + // Remove the files so the final resolve cannot mask a wrong identity. + java.nio.file.Files.delete(dir.resolve("a.jsonl")); + java.nio.file.Files.delete(dir.resolve("b.jsonl")); + + assertEquals("b", controller.resolveActiveSessionId(), + "a scan that started earlier must not undo a newer scan's result"); + } + + @Test + void claimsAreScopedToTheirSessionStore() throws Exception { + java.nio.file.Path storeA = tmpDir.resolve("configA"); + java.nio.file.Path storeB = tmpDir.resolve("configB"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dirA = sessionDir(storeA, work); + java.nio.file.Path dirB = sessionDir(storeB, work); + + // A live tab in store A runs a session "shared". + writeSession(dirA, "shared", "shared"); + setMtime(dirA.resolve("shared.jsonl"), START_MS - 5_000L); + ClaudeSessionModel modelA = new ClaudeSessionModel(); + modelA.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabA = + new ClaudeSessionController(modelA, Collections::emptyList); + tabA.simulateProcessStart(START_MS - 500L, true); + tabA.simulateLiveSession(storeA, "shared", Set.of("shared")); + + // Our tab in store B switches in-process from "previous" to a copy of + // the same conversation (same ID, different file). + writeSession(dirB, "previous", "previous"); + setMtime(dirB.resolve("previous.jsonl"), START_MS - 1_000L); + writeSession(dirB, "shared", "shared"); + setMtime(dirB.resolve("shared.jsonl"), START_MS + 2_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(storeB, "previous", Set.of("previous", "shared")); + try { + assertEquals("shared", controller.resolveActiveSessionId(), + "a claim from another store must not hide this store's file"); + } finally { + tabA.stopProcess(); + } + } + @Test void stopAndRename_usesResumeSessionId() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From d3a443f5a4ee4fdf78de94bf701be1590f5560d7 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 15:33:18 +0300 Subject: [PATCH 010/111] Preserved the selected session across session-list refreshes and moved the history reload off the EDT SessionModePanel.reload()/refreshTable() replaced the table model, which fires fireTableDataChanged() and drops the JTable selection while RESUME_SPECIFIC stays selected. After "Restart Advanced..." the selector was refreshed this way, so Open passed a null resume ID and silently launched a new conversation. The selected row is now remembered by session ID and restored after the model update, and ClaudeSessionSelectorPanel.onOpen() rejects RESUME_SPECIFIC when no row is selected. refreshSessionList() also parsed every candidate JSONL on the Swing EDT during chat teardown, pausing the IDE for projects with large transcripts. The list is now loaded on a daemon worker with a request sequence that drops superseded results, and only the table update runs on the EDT. Co-Authored-By: Claude Code --- .../ui/ClaudeSessionSelectorPanel.java | 49 +++++++++-- .../claudecodegui/ui/SessionModePanel.java | 56 +++++++++++- .../claudecodegui/ui/Bundle.properties | 1 + .../ui/SessionModePanelTest.java | 85 +++++++++++++++++++ 4 files changed, 181 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java index fc0b0285..cb955773 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java @@ -399,7 +399,10 @@ private void onProjectSelected() { private void reloadSessionModePanel() { if (sessionModePanel != null) { - reloadSessionTable(); + sessionModePanel.reload( + resolveDir() != null ? resolveDir().toPath() : null, + resolveClaudeConfigDir(), + null); sessionModePanel.setMode(ClaudeCodePreferences.getContextMenuSessionMode()); } } @@ -408,19 +411,41 @@ private void reloadSessionModePanel() { * Reloads the session list of the embedded mode panel without changing the * selected mode. Called after a session stops so a name saved by the * Save & Switch dialog is visible immediately. + * + *

The transcript files are parsed on a worker (the reload runs while the + * tab tears down its chat UI); only the table update happens on the EDT. */ public void refreshSessionList() { - if (sessionModePanel != null) { - reloadSessionTable(); + if (sessionModePanel == null) return; + File dir = resolveDir(); + java.nio.file.Path workingDir = dir != null ? dir.toPath() : null; + java.nio.file.Path configDir = resolveClaudeConfigDir(); + if (workingDir == null) { + sessionModePanel.reload(null, configDir, null); + return; } + long requestId = ++sessionListRequestSeq; + Thread loader = new Thread(() -> { + java.util.List sessions; + try { + sessions = io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .listSessions(workingDir, configDir); + } catch (RuntimeException e) { + LOG.fine("Could not reload the session list: " + e); + sessions = java.util.List.of(); + } + java.util.List loaded = sessions; + javax.swing.SwingUtilities.invokeLater(() -> { + if (requestId != sessionListRequestSeq) return; // superseded + sessionModePanel.applySessions(workingDir, configDir, null, loaded); + }); + }, "session-list-reload"); + loader.setDaemon(true); + loader.start(); } - private void reloadSessionTable() { - sessionModePanel.reload( - resolveDir() != null ? resolveDir().toPath() : null, - resolveClaudeConfigDir(), - null); - } + /** Sequence of the last asynchronous refresh, to drop superseded results. */ + private long sessionListRequestSeq; private File resolveDir() { Object item = pathCombo != null ? pathCombo.getSelectedItem() : null; @@ -468,6 +493,12 @@ private void onOpen() { saveToHistory(dir.getAbsolutePath()); } + if (!sessionModePanel.isSelectionValid()) { + showError(NbBundle.getMessage(ClaudeSessionSelectorPanel.class, + "ERR_NoSessionSelected")); + return; + } + errorLabel.setVisible(false); openListener.onOpen(dir, getSelectedProfileName(), extraArgsField.getText().trim(), sessionModePanel.getSelectedMode(), diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java index 340a034e..b5ab8ae1 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,25 @@ public void reload(Path newWorkingDir, Path newClaudeConfigDir, String newExclud updateHistoryRadiosEnabled(); } + /** + * Shows an already loaded session list for the given directory / profile. + * Selecting the same session that was selected before keeps "Resume + * specific" usable across a refresh. Must be called on the EDT. + * + * @param newWorkingDir working directory the sessions belong to + * @param newClaudeConfigDir config dir the sessions belong to (null → ~/.claude) + * @param newExcludeSession session ID to exclude, or null + * @param sessions sessions loaded by the caller + */ + public void applySessions(Path newWorkingDir, Path newClaudeConfigDir, + String newExcludeSession, List sessions) { + this.workingDir = newWorkingDir; + this.claudeConfigDir = newClaudeConfigDir; + this.excludeSession = newExcludeSession; + setSessions(sessions); + updateHistoryRadiosEnabled(); + } + /** * Returns {@code true} if the current selection is valid: any mode except * {@link SessionMode#RESUME_SPECIFIC}, or RESUME_SPECIFIC with a row selected. @@ -358,11 +377,46 @@ private void refreshTable() { tableModel.setSessions(List.of()); return; } - List sessions = ClaudeSessionStore.listSessions(workingDir, claudeConfigDir); + setSessions(ClaudeSessionStore.listSessions(workingDir, claudeConfigDir)); + } + + /** + * Replaces the table contents, keeping the row that was selected before the + * update selected (the table model fires {@code fireTableDataChanged()}, + * which otherwise drops the selection and silently turns "Resume specific" + * into an invalid choice). + */ + private void setSessions(List loaded) { + String selectedId = selectedSessionId(); + List sessions = new ArrayList<>(loaded); if (excludeSession != null) { sessions.removeIf(s -> excludeSession.equals(s.sessionId())); } tableModel.setSessions(sessions); + if (selectedId != null) { + selectSessionById(selectedId); + } + } + + /** Session ID of the selected row, regardless of the selected mode. */ + private String selectedSessionId() { + int viewRow = sessionTable.getSelectedRow(); + if (viewRow < 0) return null; + int modelRow = sessionTable.convertRowIndexToModel(viewRow); + return tableModel.getSessionId(modelRow); + } + + /** Selects the row with the given session ID, if the table still contains it. */ + void selectSessionById(String sessionId) { + for (int row = 0; row < tableModel.getRowCount(); row++) { + if (sessionId.equals(tableModel.getSessionId(row))) { + int viewRow = sessionTable.convertRowIndexToView(row); + if (viewRow >= 0) { + sessionTable.setRowSelectionInterval(viewRow, viewRow); + } + return; + } + } } private void onRename() { diff --git a/src/main/resources/io/github/nbplugins/claudecodegui/ui/Bundle.properties b/src/main/resources/io/github/nbplugins/claudecodegui/ui/Bundle.properties index 74a408af..388c484d 100644 --- a/src/main/resources/io/github/nbplugins/claudecodegui/ui/Bundle.properties +++ b/src/main/resources/io/github/nbplugins/claudecodegui/ui/Bundle.properties @@ -6,5 +6,6 @@ LBL_SelectProject=Select a project TAB_NewSession=New Session ERR_PathEmpty=Path must not be empty ERR_PathNotFound=Directory does not exist +ERR_NoSessionSelected=Select a session to resume, or choose another session mode MSG_ConfirmClose=Close all Claude Code sessions? TTL_ConfirmClose=Close Claude Code diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java index 8f599e29..65660a48 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java @@ -1,9 +1,12 @@ package io.github.nbplugins.claudecodegui.ui; +import io.github.nbplugins.claudecodegui.model.SavedSession; import io.github.nbplugins.claudecodegui.model.SessionMode; +import io.github.nbplugins.claudecodegui.process.ClaudeSessionStore; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -24,10 +27,27 @@ private Path withSession(Path workingDir) throws IOException { return workingDir.resolve(".claude"); } + /** + * Creates a sessions directory with timestamped JSONL stubs so that + * {@link ClaudeSessionStore#listSessions} returns them as table rows. + */ + private Path withSessions(Path workingDir, String... sessionIds) throws IOException { + Path configDir = workingDir.resolve(".claude"); + String hash = workingDir.toAbsolutePath().toString().replace("/", "-"); + Path sessionsSubDir = configDir.resolve("projects").resolve(hash); + Files.createDirectories(sessionsSubDir); + for (String id : sessionIds) { + Files.writeString(sessionsSubDir.resolve(id + ".jsonl"), + "{\"sessionId\":\"" + id + "\",\"timestamp\":\"2026-01-01T00:00:00Z\"}\n"); + } + return configDir; + } + // ------------------------------------------------------------------------- // No-sessions: radios disabled and selection falls back to NEW // ------------------------------------------------------------------------- + @Test void noSessions_continueAndResumeDisabled() { SessionModePanel panel = new SessionModePanel(emptyDir, null, null, false); @@ -120,6 +140,71 @@ void nullWorkingDir_defaultIsNew() { assertEquals(SessionMode.NEW, panel.getSelectedMode()); } + // ------------------------------------------------------------------------- + // Selection survives session-list refreshes + // ------------------------------------------------------------------------- + + @Test + void applySessions_keepsSelectedSessionById() throws IOException { + Path configDir = withSessions(sessionDir, "session-a", "session-b"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-a"); + assertEquals("session-a", panel.getSelectedSessionId()); + + List reloaded = ClaudeSessionStore.listSessions(sessionDir, configDir); + panel.applySessions(sessionDir, configDir, null, reloaded); + + assertEquals("session-a", panel.getSelectedSessionId(), + "The selected session must survive a background refresh"); + assertTrue(panel.isSelectionValid(), + "Resume specific must stay valid while the selected session is still listed"); + } + + @Test + void reload_keepsSelectedSessionById() throws IOException { + Path configDir = withSessions(sessionDir, "session-a", "session-b"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-b"); + + panel.reload(sessionDir, configDir, null); + + assertEquals("session-b", panel.getSelectedSessionId(), + "Reloading the same directory must keep the selected session"); + assertTrue(panel.isSelectionValid()); + } + + @Test + void applySessions_dropsSelectionWhenTheSessionIsGone() throws IOException { + Path configDir = withSessions(sessionDir, "session-a"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-a"); + assertTrue(panel.isSelectionValid()); + + panel.applySessions(sessionDir, configDir, null, List.of()); + + assertNull(panel.getSelectedSessionId()); + assertFalse(panel.isSelectionValid(), + "Resume specific without a row must not be accepted by the Open button"); + } + + @Test + void applySessions_excludedSessionIsNotSelected() throws IOException { + Path configDir = withSessions(sessionDir, "session-a", "session-b"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-a"); + + List reloaded = ClaudeSessionStore.listSessions(sessionDir, configDir); + panel.applySessions(sessionDir, configDir, "session-a", reloaded); + + assertNull(panel.getSelectedSessionId(), + "The excluded (current) session must not stay selected"); + assertFalse(panel.isSelectionValid()); + } + // ------------------------------------------------------------------------- // Selection validity // ------------------------------------------------------------------------- From 78ea35d6252893d7c7e40e5e8ee6169637185195 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 15:53:59 +0300 Subject: [PATCH 011/111] Discarded stale session-list loads and kept session claims atomic with release A background session-list load started for one project/profile could finish after the user switched to another one and replace both the table and its stored store with the previous selection. SessionModePanel now applies such a list only while the directory and config dir still match what the panel shows (applySessionsIfCurrent), so Open can no longer pair a stale session ID with the current directory and Rename cannot target the old store. Session claims used computeIfAbsent() followed by Set.add(), and the release path removed empty entries with a separate sweep. A claim racing a PTY exit could land in an owner set that had just been detached from the map, silently losing it and letting a sibling scan adopt the conversation. Claim membership and empty-entry cleanup now happen inside compute() for the key, so a claim cannot be lost. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 31 ++++++--- .../ui/ClaudeSessionSelectorPanel.java | 2 +- .../claudecodegui/ui/SessionModePanel.java | 25 +++++++ .../ClaudeSessionControllerTest.java | 69 +++++++++++++++++++ .../ui/SessionModePanelTest.java | 34 +++++++++ 5 files changed, 152 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index f66e7823..7a13626f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -719,11 +719,16 @@ private static SessionKey keyFor(java.io.File dir, java.nio.file.Path configDir, private void claimSession(java.io.File dir, java.nio.file.Path configDir, String sessionId) { SessionKey key = keyFor(dir, configDir, sessionId); - if (key != null) { - SESSION_CLAIMS - .computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()) - .add(this); - } + if (key == null) return; + // Membership and empty-entry cleanup stay inside compute() so a claim + // cannot be added to an owner set that a concurrent release just + // detached from the map (which would silently lose the claim). + SESSION_CLAIMS.compute(key, (k, owners) -> { + Set updated = + owners != null ? owners : ConcurrentHashMap.newKeySet(); + updated.add(this); + return updated; + }); } private void releaseClaim(java.io.File dir, java.nio.file.Path configDir, @@ -738,10 +743,12 @@ private void releaseClaim(java.io.File dir, java.nio.file.Path configDir, /** Removes all claims held by this controller (called when the process stops). */ private void releaseAllClaims() { - for (Set owners : SESSION_CLAIMS.values()) { - owners.remove(this); + for (SessionKey key : SESSION_CLAIMS.keySet()) { + SESSION_CLAIMS.computeIfPresent(key, (k, owners) -> { + owners.remove(this); + return owners.isEmpty() ? null : owners; + }); } - SESSION_CLAIMS.entrySet().removeIf(e -> e.getValue().isEmpty()); } /** @@ -770,6 +777,14 @@ private Set claimedByOthers(java.io.File dir, java.nio.file.Path configD return ids; } + /** + * Package-private for tests: session IDs this controller treats as claimed + * by other live tabs in the same store. + */ + Set simulateClaimedByOthers(java.io.File dir, java.nio.file.Path configDir) { + return claimedByOthers(dir, configDir); + } + /** * Returns {@code true} while this controller's PTY is running. Controllers * without a process (test seams) count as live. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java index cb955773..8e1217e3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionSelectorPanel.java @@ -437,7 +437,7 @@ public void refreshSessionList() { java.util.List loaded = sessions; javax.swing.SwingUtilities.invokeLater(() -> { if (requestId != sessionListRequestSeq) return; // superseded - sessionModePanel.applySessions(workingDir, configDir, null, loaded); + sessionModePanel.applySessionsIfCurrent(workingDir, configDir, loaded); }); }, "session-list-reload"); loader.setDaemon(true); 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 b5ab8ae1..d97001e0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/SessionModePanel.java @@ -306,6 +306,31 @@ public void applySessions(Path newWorkingDir, Path newClaudeConfigDir, updateHistoryRadiosEnabled(); } + /** + * Applies a background-loaded session list only while the panel is still + * showing the directory / profile the list was loaded for. + * + *

A reload started for the previous selection may finish after the user + * switches to another project or profile. Applying it then would replace the + * table and the stored store with stale data, so the Open button + * would pass a session ID of the old store for the new selection. Such a + * load is discarded. + * + * @param loadedWorkingDir directory the sessions were loaded for + * @param loadedConfigDir config dir the sessions were loaded for (null → ~/.claude) + * @param sessions loaded sessions + * @return {@code true} if the list was applied + */ + public boolean applySessionsIfCurrent(Path loadedWorkingDir, Path loadedConfigDir, + List sessions) { + if (!java.util.Objects.equals(this.workingDir, loadedWorkingDir) + || !java.util.Objects.equals(this.claudeConfigDir, loadedConfigDir)) { + return false; + } + applySessions(loadedWorkingDir, loadedConfigDir, this.excludeSession, sessions); + return true; + } + /** * Returns {@code true} if the current selection is valid: any mode except * {@link SessionMode#RESUME_SPECIFIC}, or RESUME_SPECIFIC with a row selected. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 47e789f0..30c219a3 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1624,6 +1624,75 @@ void processExit_releasesTheSessionClaims() throws Exception { } } + /** + * A tab may start a saved session at the same moment the tab that already + * owns it exits. The starting tab's claim must survive the exiting tab's + * cleanup; if it were lost, a sibling scan would see the file as unowned + * and could adopt (and later rename) the wrong conversation. + */ + @Test + void claimMadeWhileAnotherTabReleases_isNotLost() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + sessionDir(config, work); + + ClaudeSessionModel observerModel = new ClaudeSessionModel(); + observerModel.setWorkingDirectory(work.toFile()); + ClaudeSessionController observer = + new ClaudeSessionController(observerModel, Collections::emptyList); + observer.simulateProcessStart(START_MS, true); + observer.simulateLiveSession(config, "observer", Set.of()); + + try { + for (int i = 0; i < 2_000; i++) { + String id = "race-" + i; + + ClaudeSessionModel closingModel = new ClaudeSessionModel(); + closingModel.setWorkingDirectory(work.toFile()); + ClaudeSessionController closing = + new ClaudeSessionController(closingModel, Collections::emptyList); + closing.simulateLiveSession(config, id, Set.of()); + + ClaudeSessionModel startingModel = new ClaudeSessionModel(); + startingModel.setWorkingDirectory(work.toFile()); + ClaudeSessionController starting = + new ClaudeSessionController(startingModel, Collections::emptyList); + + java.util.concurrent.CountDownLatch go = + new java.util.concurrent.CountDownLatch(1); + Thread release = new Thread(() -> { + await(go); + closing.simulateProcessExit(); + }); + Thread claim = new Thread(() -> { + await(go); + starting.simulateLiveSession(config, id, Set.of()); + }); + release.start(); + claim.start(); + go.countDown(); + release.join(); + claim.join(); + + assertTrue( + observer.simulateClaimedByOthers(work.toFile(), config).contains(id), + "the claim of the starting tab was lost on iteration " + i); + + starting.stopProcess(); + } + } finally { + observer.stopProcess(); + } + } + + private static void await(java.util.concurrent.CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + @Test void newSessionResumingSavedConversation_resolvesAfterPrompt() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java index 65660a48..96fcf0e1 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/SessionModePanelTest.java @@ -16,6 +16,7 @@ class SessionModePanelTest { @TempDir Path emptyDir; @TempDir Path sessionDir; + @TempDir Path otherSessionDir; /** Creates a fake sessions directory with one .jsonl stub so hasAnySessions() returns true. */ private Path withSession(Path workingDir) throws IOException { @@ -205,6 +206,39 @@ void applySessions_excludedSessionIsNotSelected() throws IOException { assertFalse(panel.isSelectionValid()); } + @Test + void applySessionsIfCurrent_appliesForTheShownStore() throws IOException { + Path configDir = withSessions(sessionDir, "session-a"); + SessionModePanel panel = new SessionModePanel(sessionDir, configDir, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + + List reloaded = ClaudeSessionStore.listSessions(sessionDir, configDir); + assertTrue(panel.applySessionsIfCurrent(sessionDir, configDir, reloaded)); + } + + @Test + void applySessionsIfCurrent_discardsALoadForAnotherStore() throws IOException { + Path configA = withSessions(sessionDir, "session-a"); + Path configB = withSessions(otherSessionDir, "session-b"); + + SessionModePanel panel = new SessionModePanel(sessionDir, configA, null, false); + panel.setMode(SessionMode.RESUME_SPECIFIC); + panel.selectSessionById("session-a"); + + // The user switches to another project while a load for the old one + // is still in flight. + panel.reload(otherSessionDir, configB, null); + panel.selectSessionById("session-b"); + + List stale = ClaudeSessionStore.listSessions(sessionDir, configA); + assertFalse(panel.applySessionsIfCurrent(sessionDir, configA, stale), + "Sessions loaded for the previous selection must be discarded"); + + assertEquals("session-b", panel.getSelectedSessionId(), + "The table must still show the newly selected directory's sessions"); + assertTrue(panel.isSelectionValid()); + } + // ------------------------------------------------------------------------- // Selection validity // ------------------------------------------------------------------------- From 20ee8efb22cff40fbc89fdb22be291fec12c858d Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 16:17:02 +0300 Subject: [PATCH 012/111] Reset identity inputs atomically with the stop and tolerated coarse session mtimes stopProcess() bumped processGeneration under processOwnershipLock but cleared processStartedAt / activeConfigDir / promptSubmittedAt afterwards, outside the lock. A queued identity scan could therefore start between the two and capture the new generation together with the old run's inputs; its result was accepted after the stop released the claims, restoring the stopped identity and leaving the controller claiming both IDs. The generation bump, identity inputs, LIVE_SESSIONS membership and claim release now happen in one critical section. findLatestSessionIdWrittenSince() compares file mtimes with the launch/prompt time recorded in milliseconds, but filesystems with 1-2 s timestamp granularity round a file created right after the prompt below that cutoff. A NEW session whose first turn finished in the same timestamp bucket stayed unresolved and lost the title entered in Save & Switch. The scan now retries with a 2 s slack when the strict pass finds nothing, restricting the retry to files that did not exist in the pre-launch snapshot so a conversation touched by an unrelated process just before the prompt still cannot be adopted. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 55 +++++++++++--- .../ClaudeSessionControllerTest.java | 76 +++++++++++++++++++ 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 7a13626f..e7c7e8da 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -71,6 +71,16 @@ public class ClaudeSessionController { private static final int MAX_MODEL_DISCOVERY_ATTEMPTS = 1; + /** + * Slack for the filesystem's modification-time granularity when matching a + * session file against a launch/prompt time. FAT and some network + * filesystems round mtimes down to 1–2 s, so a file created just after a + * prompt can be stamped before it. Only used as a second pass restricted to + * files created after the pre-launch snapshot (see + * {@link #refreshSessionIdentity()}). + */ + private static final long SESSION_MTIME_GRANULARITY_MS = 2_000L; + private final ClaudeSessionModel model; /** @@ -488,9 +498,22 @@ public void stopProcess() { processToStop.stop(); } connector = null; + // 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(); + promptSubmittedAt = 0; + identityPollTicks = 0; + LIVE_SESSIONS.remove(this); + releaseAllClaims(); } terminalWidget = null; modelComboPopulated = false; @@ -499,15 +522,6 @@ public void stopProcess() { customModelIds = List.of(); standardModelCount = 0; synchronized (recentPtyLines) { recentPtyLines.clear(); } - processStartedAt = 0; - firstOutputReceived = false; - LIVE_SESSIONS.remove(this); - releaseAllClaims(); - activeConfigDir = null; - activeSessionId = null; - preStartSessionIds = Set.of(); - promptSubmittedAt = 0; - identityPollTicks = 0; model.setModelList(List.of(), -1); clearActiveChoiceMenu(); @@ -620,6 +634,18 @@ boolean refreshSessionIdentity() { if (dir != null && configDir != null && since > 0) { newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since, exclude); + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse filesystem timestamps can round a file written right + // after the prompt below its cutoff. Retry with a slack, but only + // for files that did not exist at launch (the pre-launch + // snapshot), so a conversation touched by an unrelated process + // just before the prompt still cannot be adopted. + Set relaxedExclude = new HashSet<>(exclude); + relaxedExclude.addAll(preStartSessionIds); + newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, + since - SESSION_MTIME_GRANULARITY_MS, relaxedExclude); + } } return publishIdentityScanResult(generation, scanId, dir, configDir, newest); } @@ -674,6 +700,17 @@ boolean simulatePublishScanResult(long generation, long scanId, String newest) { return publishIdentityScanResult(generation, scanId, null, activeConfigDir, newest); } + /** Package-private for tests: the identity inputs {@link #refreshSessionIdentity()} captures. */ + record IdentityCapture(long generation, boolean hasConfigDir, long since) { } + + /** Package-private for tests: samples the identity inputs under the ownership lock. */ + IdentityCapture simulateCaptureIdentityInputs() { + synchronized (processOwnershipLock) { + return new IdentityCapture(processGeneration, activeConfigDir != null, + Math.max(processStartedAt, promptSubmittedAt)); + } + } + /** * Runs the periodic identity refresh when it is due: on every call while a * {@code NEW} session is still unknown after a prompt, otherwise every diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 30c219a3..b8d110ae 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1721,6 +1721,82 @@ void newSessionResumingSavedConversation_resolvesAfterPrompt() throws Exception + "new session must still be renamable"); } + @Test + void newSessionWithCoarseMtime_resolvesAfterPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // A conversation that existed before the NEW tab was launched, so it is + // in the pre-launch snapshot and must stay ineligible. + writeSession(dir, "old", "old"); + setMtime(dir.resolve("old.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, null, Set.of("old")); + + // The first turn finishes inside one coarse (1 s) timestamp bucket: the + // file is written after the prompt but its mtime is rounded below it. + controller.simulatePromptSubmittedAt(START_MS + 1_500L); + writeSession(dir, "fresh", "fresh"); + setMtime(dir.resolve("fresh.jsonl"), START_MS + 1_000L); + + controller.stopAndRename("Fresh Name"); + + assertEquals("Fresh Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "fresh").customTitle(), + "a session whose mtime was rounded down must still be renamable"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "old").customTitle(), + "the pre-launch conversation must not be renamed instead"); + } + + /** + * A scan queued while the process is being stopped must not start with the + * new run's generation and the old run's inputs: it would publish (and + * re-claim) the stopped session after the stop released its claims. + */ + @Test + void stopProcess_resetsTheIdentityInputsWithTheGeneration() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + sessionDir(config, work); + model.setWorkingDirectory(work.toFile()); + + for (int i = 0; i < 500; i++) { + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "session", Set.of("session")); + controller.simulatePromptSubmittedAt(START_MS + 1_000L); + long before = controller.simulateProcessGeneration(); + + java.util.concurrent.atomic.AtomicReference + staleCapture = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicBoolean running = + new java.util.concurrent.atomic.AtomicBoolean(true); + Thread sampler = new Thread(() -> { + while (running.get()) { + ClaudeSessionController.IdentityCapture c = + controller.simulateCaptureIdentityInputs(); + if (c.generation() > before && (c.hasConfigDir() || c.since() != 0)) { + staleCapture.compareAndSet(null, c); + } + } + }); + sampler.setDaemon(true); + sampler.start(); + + controller.stopProcess(); + running.set(false); + sampler.join(); + + assertNull(staleCapture.get(), + "a scan starting after the generation bump captured the stopped " + + "run's identity inputs on iteration " + i); + } + } + @Test void staleExitCallback_doesNotReleaseTheReplacementRun() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From a41019fc412a4d4b03139da6287e55ae168d7a9c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 18:37:54 +0300 Subject: [PATCH 013/111] Kept sessions shared between live tabs eligible for identity scans Two tabs can resume the same saved conversation, in which case claimSession() records both as owners. claimedByOthers() then reported that session to each of them, so a tab excluded its own active file from the scan; a newer, unrelated file written by a third tab was resolved instead and stopAndRename() overwrote the other conversation's title. A key for which this controller is an owner is now not treated as claimed by others. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 6 +++ .../ClaudeSessionControllerTest.java | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index e7c7e8da..bfac1deb 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -804,6 +804,12 @@ private Set claimedByOthers(java.io.File dir, java.nio.file.Path configD || !key.projectDir().equals(wantedProject)) { return; } + // A session shared with another tab (both resumed the same saved + // conversation) is still this tab's own conversation: excluding it + // would make the scan pick an unrelated file. + if (owners.contains(this)) { + return; + } for (ClaudeSessionController owner : owners) { if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { ids.add(key.sessionId()); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index b8d110ae..2e4c862c 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1721,6 +1721,53 @@ void newSessionResumingSavedConversation_resolvesAfterPrompt() throws Exception + "new session must still be renamable"); } + /** + * Two tabs can resume the same saved conversation, in which case both own + * its session file. The shared file must stay eligible for each tab's own + * identity scan; otherwise a newer, unrelated file written by a third tab + * is resolved instead and its conversation gets renamed on stop. + */ + @Test + void sharedSessionStaysEligibleForIdentityScans() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + // The shared conversation has the newest mtime; the unrelated file was + // written after both launches but is older. + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("shared.jsonl"), START_MS + 5_000L); + writeSession(dir, "other", "other"); + setMtime(dir.resolve("other.jsonl"), START_MS + 3_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "shared", Set.of("shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 500L, true); + tabB.simulateLiveSession(config, "shared", Set.of("shared")); + + // A third tab wrote another file and exited. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "other", Set.of("other")); + tabC.simulateProcessExit(); + + try { + assertEquals("shared", controller.resolveActiveSessionId(), + "a session shared with another live tab must stay eligible"); + } finally { + tabB.stopProcess(); + } + } + @Test void newSessionWithCoarseMtime_resolvesAfterPrompt() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From 8bf925636ba87db42adc5c74c98857124759318f Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 19:03:11 +0300 Subject: [PATCH 014/111] Followed in-process resumes into another tab's session and applied the mtime slack to saved conversations The identity scan treated a file claimed by another live tab as an unconditional exclusion. When this tab joined another tab's conversation from its own resume picker, its previous file stopped changing, so no unclaimed file was written since its prompt and it kept resolving to the conversation it had left; stopAndRename() then wrote the entered title to the wrong conversation. When a known identity and a prompt of our own exist and no unclaimed file was written since that prompt, the scan now follows the newest file written since the prompt even if another live tab owns it, sharing the ownership instead of ignoring the switch. While this tab keeps writing its own conversation the strict pass already finds that file, so another tab's newer file is still not adopted. The coarse-timestamp retry previously excluded every pre-launch session ID, which also rejected a saved conversation resumed in-process (such files exist before the launch). The retry now only applies that snapshot exclusion when the tab has not submitted a prompt yet, so the rounded mtime is tolerated for resumed saved conversations while pre-launch files still cannot be adopted before a prompt. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 41 ++++-- .../ClaudeSessionControllerTest.java | 125 ++++++++++++++++++ 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index bfac1deb..cb246cb2 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -75,8 +75,8 @@ public class ClaudeSessionController { * Slack for the filesystem's modification-time granularity when matching a * session file against a launch/prompt time. FAT and some network * filesystems round mtimes down to 1–2 s, so a file created just after a - * prompt can be stamped before it. Only used as a second pass restricted to - * files created after the pre-launch snapshot (see + * prompt — including a saved conversation resumed in-process — can be + * stamped before it. Applied as a second identity-scan pass (see * {@link #refreshSessionIdentity()}). */ private static final long SESSION_MTIME_GRANULARITY_MS = 2_000L; @@ -610,15 +610,21 @@ boolean refreshSessionIdentity() { java.io.File dir; java.nio.file.Path configDir; long since; + long promptAt; + String activeId; Set exclude = new HashSet<>(); + Set claimed = new HashSet<>(); synchronized (processOwnershipLock) { generation = processGeneration; scanId = ++scanSequence; dir = workingDir != null ? workingDir : model.getWorkingDirectory(); configDir = activeConfigDir; since = Math.max(processStartedAt, promptSubmittedAt); + promptAt = promptSubmittedAt; + activeId = activeSessionId; if (dir != null && configDir != null && since > 0) { - exclude.addAll(claimedByOthers(dir, configDir)); + claimed.addAll(claimedByOthers(dir, configDir)); + exclude.addAll(claimed); if (activeSessionId == null && promptSubmittedAt <= 0) { // Without a prompt of our own there is nothing that proves a // write belongs to this tab, so a file that existed before @@ -636,16 +642,33 @@ boolean refreshSessionIdentity() { dir.toPath(), configDir, since, exclude); if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { // Coarse filesystem timestamps can round a file written right - // after the prompt below its cutoff. Retry with a slack, but only - // for files that did not exist at launch (the pre-launch - // snapshot), so a conversation touched by an unrelated process - // just before the prompt still cannot be adopted. - Set relaxedExclude = new HashSet<>(exclude); - relaxedExclude.addAll(preStartSessionIds); + // after the prompt below its cutoff. Retry with a slack so a + // saved conversation resumed in-process (which existed before + // the launch) is still recognised. Without a prompt of our own + // the pre-launch snapshot stays excluded: the extra second must + // not make a file touched by another process eligible. + Set relaxedExclude = exclude; + if (promptAt <= 0) { + relaxedExclude = new HashSet<>(exclude); + relaxedExclude.addAll(preStartSessionIds); + } newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since - SESSION_MTIME_GRANULARITY_MS, relaxedExclude); } + if (newest == null && activeId != null && promptAt > 0) { + // The process may have joined another live tab's conversation + // from its own resume picker: its previous file stops changing, + // so only a file owned by the other tab is written since the + // prompt. Follow the switch and share the ownership, instead of + // keeping the conversation the tab had left. + String switched = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, since, Set.of()); + if (switched != null && !switched.equals(activeId) + && claimed.contains(switched)) { + newest = switched; + } + } } return publishIdentityScanResult(generation, scanId, dir, configDir, newest); } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 2e4c862c..72aaaed8 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1768,6 +1768,131 @@ void sharedSessionStaysEligibleForIdentityScans() throws Exception { } } + /** + * A tab can join another live tab's conversation from the in-process resume + * picker. The other tab still owns that file, so it must not stay an + * unconditional exclusion: the tab follows the switch and shares ownership. + */ + @Test + void inProcessResumeIntoAnotherTabsSession_followsTheSwitch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // Tab A picks B's conversation in its TUI and sends a prompt; the + // write lands in B's file, which B still owns. + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "a tab that resumed another tab's conversation must follow it"); + + controller.stopAndRename("Switched Name"); + assertEquals("Switched Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the entered name must go to the conversation on screen"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation left by the switch must not be renamed"); + } finally { + tabB.stopProcess(); + } + } + + /** + * 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"); From 58c3394234f948ca13e57711bd182632cb928010 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 19:30:21 +0300 Subject: [PATCH 015/111] Allowed an unresolved NEW tab to follow an in-process resume into a shared session A NEW tab can join another live tab's conversation from its own resume picker before it has created a JSONL of its own, so its identity is still unknown when the prompt is sent. The ownership-bypassing scan required a known identity and therefore never ran, leaving the tab unresolved; stopAndRename() then skipped saving the entered title. The scan now also runs for an unknown identity once the tab has submitted a prompt, still requiring the followed file to be owned by another live tab. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 12 +++-- .../ClaudeSessionControllerTest.java | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index cb246cb2..5c443820 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -656,12 +656,14 @@ boolean refreshSessionIdentity() { dir.toPath(), configDir, since - SESSION_MTIME_GRANULARITY_MS, relaxedExclude); } - if (newest == null && activeId != null && promptAt > 0) { + if (newest == null && promptAt > 0) { // The process may have joined another live tab's conversation - // from its own resume picker: its previous file stops changing, - // so only a file owned by the other tab is written since the - // prompt. Follow the switch and share the ownership, instead of - // keeping the conversation the tab had left. + // from its own resume picker: its previous file (if any) stops + // changing, so only a file owned by the other tab is written + // since the prompt. Follow the switch and share the ownership, + // instead of keeping the conversation the tab had left. A NEW + // tab can do this before it has created a file of its own, so + // an unknown identity must be allowed as well. String switched = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since, Set.of()); if (switched != null && !switched.equals(activeId) diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 72aaaed8..d8689908 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1817,6 +1817,52 @@ void inProcessResumeIntoAnotherTabsSession_followsTheSwitch() throws Exception { } } + /** + * A NEW tab can join another live tab's conversation from the in-process + * resume picker before it has created a JSONL of its own, so its identity is + * still unknown when the prompt is sent. + */ + @Test + void newTabResumingAnotherTabsConversationBeforeItsOwnFile_resolves() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + // Tab B owns the conversation. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("shared")); + + // Tab A is NEW and has not written anything yet. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("shared")); + + try { + // A joins B's conversation in the TUI and sends a prompt; the write + // lands in the shared file. + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 11_000L); + + assertEquals("shared", controller.resolveActiveSessionId(), + "a NEW tab that resumed a live tab's conversation must resolve it"); + + controller.stopAndRename("Shared Name"); + assertEquals("Shared Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "shared").customTitle(), + "the entered name must be saved to the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * The counterpart of the test above: while this tab keeps writing its own * conversation, another tab's newer file must not be adopted. From 67ec84dbebac19ce55c929044090e77644b47853 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 19:52:45 +0300 Subject: [PATCH 016/111] Required evidence before following an in-process resume and skipped identity scans for blank-name stops Adopting another live tab's session on the third identity-scan pass now requires that the file contains the prompt this tab submitted after promptSubmittedAt (new ClaudeSessionStore.containsUserPromptSince). A NEW tab's first prompt is scanned before its own JSONL exists, so a sibling tab finishing a turn in the same store could previously be mistaken for an in-process /resume and have its conversation renamed on stop. stopAndRename() no longer resolves the session identity when the name is null or blank: the prompt panel's "New session" action stops with a blank name, no rename follows, and resolving enumerated the session store on the EDT (138-260 ms on projects with 30,000 conversations). Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 42 ++++++++- .../process/ClaudeSessionStore.java | 59 +++++++++++++ .../ClaudeSessionControllerTest.java | 88 +++++++++++++++++++ .../process/ClaudeSessionStoreTest.java | 63 +++++++++++++ 4 files changed, 249 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 5c443820..86fc7b59 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -196,6 +196,14 @@ private record SessionKey(java.nio.file.Path configDir, */ private volatile long promptSubmittedAt = 0; + /** + * Text of the last prompt submitted from the plugin. Together with + * {@link #promptSubmittedAt} it is the evidence that a file written after + * the prompt contains this tab's own messages — e.g. a conversation resumed + * in-process — rather than a concurrent turn of a sibling tab. + */ + private volatile String lastPromptText; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -294,6 +302,18 @@ void simulatePromptSubmittedAt(long millis) { promptSubmittedAt = millis; } + /** Package-private for tests: records the text of the last submitted prompt. */ + void simulatePromptText(String text) { + lastPromptText = text; + } + + /** Package-private for tests: the number of identity scans started. */ + long simulateScanSequence() { + synchronized (processOwnershipLock) { + return scanSequence; + } + } + /** * Package-private for tests: marks this controller as a live session, so * that other controllers treat {@code sessionId} as owned by another tab. @@ -511,6 +531,7 @@ public void stopProcess() { activeSessionId = null; preStartSessionIds = Set.of(); promptSubmittedAt = 0; + lastPromptText = null; identityPollTicks = 0; LIVE_SESSIONS.remove(this); releaseAllClaims(); @@ -541,12 +562,17 @@ public void stopProcess() { * @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; - String sessionId = resolveActiveSessionId(); + // 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 = rename ? resolveActiveSessionId() : null; stopProcess(); - if (sessionName == null || sessionName.isBlank()) return; + if (!rename) return; if (dir == null || configDir == null || sessionId == null) { LOG.warning("Could not rename session: no active session file found in " + ClaudeSessionStore.sessionDirForLog( @@ -611,6 +637,7 @@ boolean refreshSessionIdentity() { java.nio.file.Path configDir; long since; long promptAt; + String promptText; String activeId; Set exclude = new HashSet<>(); Set claimed = new HashSet<>(); @@ -621,6 +648,7 @@ boolean refreshSessionIdentity() { configDir = activeConfigDir; since = Math.max(processStartedAt, promptSubmittedAt); promptAt = promptSubmittedAt; + promptText = lastPromptText; activeId = activeSessionId; if (dir != null && configDir != null && since > 0) { claimed.addAll(claimedByOthers(dir, configDir)); @@ -664,10 +692,17 @@ boolean refreshSessionIdentity() { // instead of keeping the conversation the tab had left. A NEW // tab can do this before it has created a file of its own, so // an unknown identity must be allowed as well. + // + // The file must contain this tab's own prompt, written after it + // was submitted: without that evidence a sibling tab finishing + // a turn in the same store — a write that may land before this + // tab's first JSONL line — would be mistaken for a switch. String switched = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since, Set.of()); if (switched != null && !switched.equals(activeId) - && claimed.contains(switched)) { + && claimed.contains(switched) + && ClaudeSessionStore.containsUserPromptSince( + dir.toPath(), configDir, switched, promptAt, promptText)) { newest = switched; } } @@ -923,6 +958,7 @@ public void sendPrompt(String text) { if (!text.isBlank()) { model.addPromptToHistory(text); } + lastPromptText = text; promptSubmittedAt = System.currentTimeMillis(); scheduleIdentityRefresh(); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 9e76f6be..5cdc19f0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -194,6 +194,65 @@ public static String findLatestSessionIdWrittenSince(Path workingDir, Path claud return newest != null ? sessionIdOf(newest) : null; } + /** + * Returns whether the session file contains a user message with + * {@code prompt} whose timestamp is at or after {@code sinceMillis}. + * + *

This is the evidence that a running process — and not a sibling tab + * writing its own conversation — is the one appending to the file: a + * conversation resumed in-process contains the prompt this tab just + * submitted, while another tab's turn contains only that tab's messages. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sessionId session ID (file name without {@code .jsonl}) + * @param sinceMillis earliest acceptable message timestamp (epoch millis) + * @param prompt prompt text submitted by this tab + * @return {@code true} if such a message exists; {@code false} otherwise + */ + public static boolean containsUserPromptSince(Path workingDir, Path claudeConfigDir, + String sessionId, long sinceMillis, + String prompt) { + if (sessionId == null || prompt == null || prompt.isBlank()) return false; + Path file = sessionsDir(workingDir, claudeConfigDir).resolve(sessionId + ".jsonl"); + if (!Files.isRegularFile(file)) return false; + String wanted = prompt.trim(); + if (wanted.isEmpty()) return false; + try (Stream lines = Files.lines(file, StandardCharsets.UTF_8)) { + return lines.anyMatch(line -> isUserMessageWith(line, sinceMillis, wanted)); + } catch (IOException | RuntimeException e) { + LOG.fine("Could not read session " + file + ": " + e.getMessage()); + return false; + } + } + + /** Returns whether the JSONL {@code line} is a user message with {@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; + 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 content.asText().contains(prompt); + if (content.isArray()) { + for (JsonNode block : content) { + if (block.isTextual() && block.asText().contains(prompt)) return true; + if (block.isObject() && "text".equals(block.path("type").asText()) + && block.path("text").asText().contains(prompt)) { + return true; + } + } + } + return false; + } catch (Exception e) { + return false; + } + } + private static Path newestSessionFile(Path dir, Set excludeIds, long minMtimeMillis) { if (!Files.isDirectory(dir)) { return null; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index d8689908..c5f714fb 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1411,6 +1411,16 @@ private static void writeSession(java.nio.file.Path dir, String id, String slug) + "\",\"timestamp\":\"2024-01-01T10:00:00Z\",\"type\":\"summary\"}\n"); } + /** Appends a user message, as the running process writes it into the JSONL. */ + private static void appendUserPrompt(java.nio.file.Path dir, String id, String text, + long millis) throws java.io.IOException { + java.nio.file.Files.writeString(dir.resolve(id + ".jsonl"), + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"" + text + + "\"},\"timestamp\":\"" + java.time.Instant.ofEpochMilli(millis) + + "\"}\n", + java.nio.file.StandardOpenOption.APPEND); + } + private static void setMtime(java.nio.file.Path file, long millis) throws java.io.IOException { java.nio.file.Files.setLastModifiedTime(file, java.nio.file.attribute.FileTime.fromMillis(millis)); @@ -1798,7 +1808,9 @@ void inProcessResumeIntoAnotherTabsSession_followsTheSwitch() throws Exception { try { // Tab A picks B's conversation in its TUI and sends a prompt; the // write lands in B's file, which B still owns. + controller.simulatePromptText("continue in the resumed tab"); controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "b", "continue in the resumed tab", START_MS + 10_500L); setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); assertEquals("b", controller.resolveActiveSessionId(), @@ -1847,7 +1859,9 @@ void newTabResumingAnotherTabsConversationBeforeItsOwnFile_resolves() throws Exc try { // A joins B's conversation in the TUI and sends a prompt; the write // lands in the shared file. + controller.simulatePromptText("joined from a new tab"); controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "shared", "joined from a new tab", START_MS + 10_500L); setMtime(dir.resolve("shared.jsonl"), START_MS + 11_000L); assertEquals("shared", controller.resolveActiveSessionId(), @@ -1863,6 +1877,80 @@ void newTabResumingAnotherTabsConversationBeforeItsOwnFile_resolves() throws Exc } } + /** + * The counterpart of the test above: a NEW tab's first prompt is scanned + * before the tab has written its own file. A sibling tab finishing a turn at + * that moment owns a file written after the prompt, but it contains only + * that tab's messages — not this tab's prompt — so it must not be adopted. + */ + @Test + void newTabPromptingWhileAnotherTabWrites_doesNotAdoptItsSession() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("b")); + + try { + // A's scan runs before A's own file exists; meanwhile B finishes a + // turn in the same store. + controller.simulatePromptText("a brand new prompt"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "b", "b's own turn", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertNull(controller.resolveActiveSessionId(), + "another tab's turn is not evidence of a shared resume"); + + controller.stopAndRename("A Name"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "a pending first prompt must not rename another tab's conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * 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 counterpart of the test above: while this tab keeps writing its own * conversation, another tab's newer file must not be adopted. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 229b1e85..379e39ec 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -304,6 +304,69 @@ void findLatestSessionIdWrittenSince_picksNewestAndSkipsExcluded() throws IOExce workDir, tmpDir, BASE_MTIME, java.util.Set.of())); } + // ------------------------------------------------------------------------- + // containsUserPromptSince + // ------------------------------------------------------------------------- + + private static String userLine(String text, java.time.Instant ts) { + return "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"" + + text + "\"},\"timestamp\":\"" + ts + "\"}"; + } + + @Test + void containsUserPromptSince_findsPromptWrittenAfterSubmission() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, + userLine("earlier question", OLD_TS), + userLine("the prompt from this tab", OLD_TS.plusSeconds(60))); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.plusSeconds(30).toEpochMilli(), "the prompt from this tab")); + } + + @Test + void containsUserPromptSince_ignoresOlderMessagesWithTheSameText() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, userLine("continue", OLD_TS)); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.plusSeconds(30).toEpochMilli(), "continue"), + "a message written before the prompt is not evidence of a shared resume"); + } + + @Test + void containsUserPromptSince_acceptsContentBlocks() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + String ts = OLD_TS.plusSeconds(60).toString(); + writeJsonl(file, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"prompt in a block\"}]},\"timestamp\":\"" + + ts + "\"}"); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "prompt in a block")); + } + + @Test + void containsUserPromptSince_noEvidence_returnsFalse() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeJsonl(sessionDir.resolve("s1.jsonl"), + userLine("another tabs prompt", OLD_TS.plusSeconds(60))); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "this tabs prompt")); + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "missing", + OLD_TS.toEpochMilli(), "whatever")); + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), " ")); + } + @Test void findSession_returnsRequestedSessionOnly() throws IOException { Path workDir = tmpDir.resolve("myproject"); From 7e28a190c9d1fab100f724bdf3b3b99dd404bfd9 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 20:22:00 +0300 Subject: [PATCH 017/111] Preferred prompt evidence over mtime guesses and prepared the launch identity off the EDT refreshSessionIdentity() now checks the conversations owned by live tabs (and this tab) for the prompt submitted after promptSubmittedAt before falling back to the newest file. This is the strongest evidence of an in-process /resume: a third tab that writes its own conversation after the prompt and exits leaves an unclaimed and newer file that must not win over the conversation holding the prompt. ClaudeSessionStore.containsUserPromptSince() reads only the file tail, so a long conversation is not parsed on every scan. The launch-time session identity (pre-launch snapshot and the --continue target) is now computed by ClaudeSessionController.prepareLaunchIdentity() on a worker thread in ClaudeSessionTab before the process starts, instead of enumerating the session store on the Swing EDT. The prepared LaunchIdentity is passed to a new startProcess() overload, preserving the before-launch ordering while removing the IDE freeze on projects with large histories. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 156 +++++++++++++----- .../process/ClaudeSessionStore.java | 30 +++- .../claudecodegui/ui/ClaudeSessionTab.java | 53 +++++- .../ClaudeSessionControllerTest.java | 118 +++++++++++++ 4 files changed, 309 insertions(+), 48 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 86fc7b59..36036da4 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -391,6 +391,80 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr public void startProcess(java.io.File dir, String profileName, String extraCliArgs, SessionMode mode, String resumeSessionId, JediTermWidget widget) throws IOException { + startProcess(dir, profileName, extraCliArgs, mode, resumeSessionId, + prepareLaunchIdentity(dir, profileName, mode, resumeSessionId), widget); + } + + /** + * Launch-time session identity, captured before the PTY starts. + * + * @param configDir config dir the process will write to + * @param preStartSessionIds session IDs that already existed at launch + * @param sessionId session ID the process will use, or {@code null} + * when it is only known after the first write + */ + public record LaunchIdentity(java.nio.file.Path configDir, + Set preStartSessionIds, + String sessionId) { } + + /** + * Computes the launch-time session identity by scanning the session store. + * + *

The scan can take hundreds of milliseconds on projects with a large + * history, so callers on the EDT should run it on a worker thread and pass + * the result to + * {@link #startProcess(java.io.File, String, String, SessionMode, String, LaunchIdentity, JediTermWidget)}. + * The result must describe the store before the process starts. + * + * @param dir working directory + * @param profileName profile name, or {@code null}/{@code ""} for Default + * @param mode session start mode + * @param resumeSessionId session ID to resume (only used for RESUME_SPECIFIC) + * @return the identity the started process will use + */ + public static LaunchIdentity prepareLaunchIdentity(java.io.File dir, String profileName, + SessionMode mode, + String resumeSessionId) { + ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); + return prepareLaunchIdentity(dir, + ClaudeProcess.resolveConfigDir(profile, ClaudeCodePreferences.getProfilesDir()), + mode, resumeSessionId); + } + + /** Package-private for tests: captures the identity against an explicit config dir. */ + static LaunchIdentity prepareLaunchIdentity(java.io.File dir, + java.nio.file.Path configDir, + SessionMode mode, + String resumeSessionId) { + SessionMode effectiveMode = mode != null ? mode : SessionMode.NEW; + Set preStartSessionIds = + ClaudeSessionStore.listSessionIds(dir.toPath(), configDir); + String sessionId = null; + if (effectiveMode == SessionMode.RESUME_SPECIFIC + && resumeSessionId != null && !resumeSessionId.isBlank()) { + sessionId = resumeSessionId; + } else if (effectiveMode == SessionMode.CONTINUE_LAST) { + sessionId = ClaudeSessionStore.findLatestSessionId(dir.toPath(), configDir); + } + return new LaunchIdentity(configDir, preStartSessionIds, sessionId); + } + + /** + * 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; @@ -401,20 +475,13 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); customModelIds = profile != null ? new ArrayList<>(profile.getComboModelIds()) : List.of(); - // Capture the session identity before launch so that stopping/renaming later - // targets the session this process will actually use (see stopAndRename). + // The session identity was captured before launch so that stopping/renaming + // later targets the session this process will actually use (see stopAndRename). SessionMode effectiveMode = mode != null ? mode : SessionMode.NEW; - java.nio.file.Path projectedConfigDir = - ClaudeProcess.resolveConfigDir(profile, ClaudeCodePreferences.getProfilesDir()); - preStartSessionIds = ClaudeSessionStore.listSessionIds(dir.toPath(), projectedConfigDir); - if (effectiveMode == SessionMode.RESUME_SPECIFIC - && resumeSessionId != null && !resumeSessionId.isBlank()) { - activeSessionId = resumeSessionId; - } else if (effectiveMode == SessionMode.CONTINUE_LAST) { - activeSessionId = ClaudeSessionStore.findLatestSessionId(dir.toPath(), projectedConfigDir); - } else { - activeSessionId = null; - } + LaunchIdentity launch = identity != null ? identity + : prepareLaunchIdentity(dir, profileName, effectiveMode, resumeSessionId); + preStartSessionIds = launch.preStartSessionIds(); + activeSessionId = launch.sessionId(); PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : "", @@ -422,7 +489,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr activeConfigDir = claudeProcess.getConfigDir(); if (activeConfigDir == null) { - activeConfigDir = projectedConfigDir; + activeConfigDir = launch.configDir(); } processStartedAt = System.currentTimeMillis(); firstOutputReceived = false; @@ -666,8 +733,21 @@ boolean refreshSessionIdentity() { } String newest = null; if (dir != null && configDir != null && since > 0) { - newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( - dir.toPath(), configDir, since, exclude); + // A conversation owned by a live tab (or by this tab) that already + // contains the prompt submitted from here is the strongest evidence + // of an in-process /resume: the process is writing that file. It + // must win over a merely newer unclaimed file — e.g. a third tab + // that wrote its own conversation after the prompt and then exited. + if (promptAt > 0) { + Set candidates = new HashSet<>(claimed); + if (activeId != null) candidates.add(activeId); + newest = findPromptBearingSession(dir, configDir, candidates, + promptAt, promptText); + } + if (newest == null) { + newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, since, exclude); + } if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { // Coarse filesystem timestamps can round a file written right // after the prompt below its cutoff. Retry with a slack so a @@ -684,32 +764,32 @@ boolean refreshSessionIdentity() { dir.toPath(), configDir, since - SESSION_MTIME_GRANULARITY_MS, relaxedExclude); } - if (newest == null && promptAt > 0) { - // The process may have joined another live tab's conversation - // from its own resume picker: its previous file (if any) stops - // changing, so only a file owned by the other tab is written - // since the prompt. Follow the switch and share the ownership, - // instead of keeping the conversation the tab had left. A NEW - // tab can do this before it has created a file of its own, so - // an unknown identity must be allowed as well. - // - // The file must contain this tab's own prompt, written after it - // was submitted: without that evidence a sibling tab finishing - // a turn in the same store — a write that may land before this - // tab's first JSONL line — would be mistaken for a switch. - String switched = ClaudeSessionStore.findLatestSessionIdWrittenSince( - dir.toPath(), configDir, since, Set.of()); - if (switched != null && !switched.equals(activeId) - && claimed.contains(switched) - && ClaudeSessionStore.containsUserPromptSince( - dir.toPath(), configDir, switched, promptAt, promptText)) { - newest = switched; - } - } } return publishIdentityScanResult(generation, scanId, dir, configDir, newest); } + /** + * Returns the session among {@code candidates} whose JSONL already contains + * the prompt this tab submitted after {@code promptAt}, or {@code null}. + * + *

The message proves that the running process has joined that + * conversation, so it is preferred over an mtime-only guess: a sibling tab + * finishing its own turn also writes a file after the prompt, but that file + * holds that tab's messages, not this tab's prompt. + */ + private String findPromptBearingSession(java.io.File dir, java.nio.file.Path configDir, + Set candidates, long promptAt, + String promptText) { + if (candidates.isEmpty() || promptText == null || promptText.isBlank()) return null; + for (String sessionId : candidates) { + if (ClaudeSessionStore.containsUserPromptSince( + dir.toPath(), configDir, sessionId, promptAt, promptText)) { + return sessionId; + } + } + return null; + } + /** * Publishes a scan result, unless it was superseded: a scan started before a * process restart describes a store the current run does not use, and a scan diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 5cdc19f0..dc1c35a7 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -32,6 +32,14 @@ public final class ClaudeSessionStore { private static final Logger LOG = Logger.getLogger(ClaudeSessionStore.class.getName()); private static final ObjectMapper MAPPER = new ObjectMapper(); + /** + * How much of a session file is inspected when looking for the prompt a + * running process just appended. The evidence is written within a second of + * the prompt, so the tail is enough and a long conversation does not have to + * be read in full on every identity scan. + */ + private static final int PROMPT_EVIDENCE_TAIL_BYTES = 512 * 1024; + private ClaudeSessionStore() {} // ------------------------------------------------------------------------- @@ -218,8 +226,26 @@ public static boolean containsUserPromptSince(Path workingDir, Path claudeConfig if (!Files.isRegularFile(file)) return false; String wanted = prompt.trim(); if (wanted.isEmpty()) return false; - try (Stream lines = Files.lines(file, StandardCharsets.UTF_8)) { - return lines.anyMatch(line -> isUserMessageWith(line, sinceMillis, wanted)); + try { + long size = Files.size(file); + int tailSize = (int) Math.min(size, PROMPT_EVIDENCE_TAIL_BYTES); + byte[] tail = new byte[tailSize]; + try (java.io.RandomAccessFile raf = + new java.io.RandomAccessFile(file.toFile(), "r")) { + raf.seek(size - tailSize); + raf.readFully(tail); + } + String text = new String(tail, StandardCharsets.UTF_8); + if (tailSize < size) { + // The first line is cut in the middle; it belongs to history. + int firstNewline = text.indexOf('\n'); + if (firstNewline < 0) return false; + text = text.substring(firstNewline + 1); + } + for (String line : text.split("\n")) { + if (isUserMessageWith(line, sinceMillis, wanted)) return true; + } + return false; } catch (IOException | RuntimeException e) { LOG.fine("Could not read session " + file + ": " + e.getMessage()); return false; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index fb5f99dd..c151d0b2 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -166,6 +166,13 @@ public class ClaudeSessionTab extends TopComponent /** Resume session ID actually used when the current session was started. */ private String activeResumeSessionId; + /** + * Bumped for every session start and on close, so a launch whose + * preparation finished late cannot start a process the user already + * replaced or closed. + */ + private volatile long startRequestSeq; + // ------------------------------------------------------------------------- // MVC // ------------------------------------------------------------------------- @@ -468,6 +475,7 @@ protected void componentClosed() { 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); @@ -848,14 +856,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 || tabClosed) 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) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index c5f714fb..4b7bbfe0 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1923,6 +1923,124 @@ void newTabPromptingWhileAnotherTabWrites_doesNotAdoptItsSession() throws Except } } + /** + * A third tab can write its own conversation after this tab's prompt and + * then exit, leaving an unclaimed and even newer file. The conversation that + * received this tab's prompt — still owned by its other tab — must win over + * that mtime-only candidate. + */ + @Test + void promptEvidenceWinsOverANewerUnclaimedFile() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b", "c")); + + // A third tab writes its own conversation after the prompt and exits, + // so its file is the newest and no longer claimed by a live tab. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 2_000L, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "b", "c")); + setMtime(dir.resolve("c.jsonl"), START_MS + 12_000L); + tabC.stopProcess(); + + try { + // Tab A joined B's conversation and sent its prompt there. + controller.simulatePromptText("joined b"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "b", "joined b", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the file holding this tab's prompt must beat a newer unclaimed one"); + + controller.stopAndRename("Resumed Name"); + assertEquals("Resumed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the entered name must go to the conversation on screen"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "c").customTitle(), + "the exited tab's conversation must not be renamed"); + } finally { + tabB.stopProcess(); + } + } + + // ------------------------------------------------------------------------- + // 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 From 1aa434f7bc1a345cf0d2439bc652d3fc97258fce Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 21:18:10 +0300 Subject: [PATCH 018/111] Hardened session tracking against terminal input and stale tab state Prompt evidence is what lets a tab follow an in-process /resume into a session another tab already owns. Three ways it could be wrong are closed: - Text typed directly in the terminal (not sent by the plugin) can switch the conversation, so the prompt submitted earlier no longer describes what is on screen. PtyTtyConnector now reports every write through an input listener; the controller marks its own writes on the calling thread via a ThreadLocal and treats any other write as external input, which disables the evidence pass. - Evidence now requires the whole stored message to equal the submitted prompt, instead of merely containing it: a prompt such as "yes" no longer matches an unrelated message that happens to contain it. - The launch guard no longer tests tabClosed, which is never reset when a closed tab is reopened; the per-launch request sequence already cancels a launch whose tab was closed in the meantime. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 136 ++++++++++++------ .../process/ClaudeSessionStore.java | 34 ++++- .../process/PtyTtyConnector.java | 15 ++ .../claudecodegui/ui/ClaudeSessionTab.java | 2 +- .../ClaudeSessionControllerTest.java | 41 ++++++ .../process/ClaudeSessionStoreTest.java | 17 +++ .../process/PtyTtyConnectorTest.java | 24 ++++ 7 files changed, 220 insertions(+), 49 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 36036da4..98a493b8 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -204,6 +204,20 @@ private record SessionKey(java.nio.file.Path configDir, */ private volatile String lastPromptText; + /** + * True while the current thread writes to the PTY on behalf of the plugin + * (see {@link #writePty(byte[])}), so terminal input can be told apart. + */ + private final ThreadLocal pluginPtyWrite = ThreadLocal.withInitial(() -> false); + + /** + * Time (epoch millis) of the last PTY input typed in the terminal rather + * than sent by the plugin. Such input can change the conversation (an + * in-process {@code /resume}), so prompt evidence captured before it no + * longer describes the session on screen. + */ + private volatile long externalInputAt; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -499,6 +513,10 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr connector = new PtyTtyConnector(process); connector.setSessionTag(tag); + connector.setInputListener(bytes -> { + if (Boolean.TRUE.equals(pluginPtyWrite.get())) return; + externalInputAt = System.currentTimeMillis(); + }); screenContentDetector.setSessionTag(tag); connector.setLineListener(line -> { @@ -599,6 +617,7 @@ public void stopProcess() { preStartSessionIds = Set.of(); promptSubmittedAt = 0; lastPromptText = null; + externalInputAt = 0; identityPollTicks = 0; LIVE_SESSIONS.remove(this); releaseAllClaims(); @@ -704,6 +723,7 @@ boolean refreshSessionIdentity() { java.nio.file.Path configDir; long since; long promptAt; + long externalAt; String promptText; String activeId; Set exclude = new HashSet<>(); @@ -715,6 +735,7 @@ boolean refreshSessionIdentity() { configDir = activeConfigDir; since = Math.max(processStartedAt, promptSubmittedAt); promptAt = promptSubmittedAt; + externalAt = externalInputAt; promptText = lastPromptText; activeId = activeSessionId; if (dir != null && configDir != null && since > 0) { @@ -738,7 +759,11 @@ boolean refreshSessionIdentity() { // of an in-process /resume: the process is writing that file. It // must win over a merely newer unclaimed file — e.g. a third tab // that wrote its own conversation after the prompt and then exited. - if (promptAt > 0) { + // + // Text typed in the terminal after that prompt may have switched the + // conversation through the TUI, in which case the old prompt is no + // longer evidence of what is on screen: fall back to file times. + if (promptAt > 0 && promptAt > externalAt) { Set candidates = new HashSet<>(claimed); if (activeId != null) candidates.add(activeId); newest = findPromptBearingSession(dir, configDir, candidates, @@ -1044,9 +1069,9 @@ public void sendPrompt(String text) { Thread t = new Thread(() -> { try { - connector.write(text); + writePty(text); Thread.sleep(200); - connector.write("\r"); + writePty("\r"); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -1127,11 +1152,11 @@ 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'}); + writePty(new byte[]{0x1b, '[', 'C'}); } catch (IOException ex) { LOG.warning("writePtyAnswer MULTI write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -1164,7 +1189,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 @@ -1176,18 +1201,18 @@ 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'}); + writePty(new byte[]{0x1b, '[', 'C'}); } catch (IOException ex) { LOG.warning("writePtyAnswer MULTI_TYPE write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -1217,20 +1242,20 @@ public void writePtyAnswer(String answer) { if (delta != 0) { byte[] arrow = delta < 0 ? 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); } Thread.sleep(100); } - connector.write(text); + writePty(text); Thread.sleep(200); - connector.write("\r"); + writePty("\r"); } else { - connector.write(digit); + writePty(digit); Thread.sleep(200); - connector.write(text); + writePty(text); Thread.sleep(200); - connector.write("\r"); + writePty("\r"); } } catch (IOException ex) { LOG.warning("writePtyAnswer TYPE write failed: " + ex.getMessage()); @@ -1249,16 +1274,16 @@ public void writePtyAnswer(String answer) { LOG.fine("[PTY write] ARROW targetIdx=" + targetIdx + " currentIdx=" + currentIdx + " delta=" + delta); clearActiveChoiceMenu(); if (delta == 0) { - connector.write("\r"); + writePty("\r"); } else { byte[] arrow = delta < 0 ? new byte[]{0x1b, '[', 'A'} : new byte[]{0x1b, '[', 'B'}; Thread t = new Thread(() -> { try { for (int idx = 0; idx < Math.abs(delta); idx++) { - connector.write(arrow); + writePty(arrow); Thread.sleep(80); } - connector.write("\r"); + writePty("\r"); } catch (IOException ex) { LOG.warning("writePtyAnswer ARROW write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -1283,16 +1308,16 @@ public void writePtyAnswer(String answer) { LOG.fine("[PTY write] ARROW-digit targetIdx=" + targetIdx + " currentIdx=" + currentIdx + " delta=" + delta); clearActiveChoiceMenu(); if (delta == 0) { - connector.write("\r"); + writePty("\r"); } else { byte[] arrow = delta < 0 ? new byte[]{0x1b, '[', 'A'} : new byte[]{0x1b, '[', 'B'}; Thread t = new Thread(() -> { try { for (int i = 0; i < Math.abs(delta); i++) { - connector.write(arrow); + writePty(arrow); Thread.sleep(80); } - connector.write("\r"); + writePty("\r"); } catch (IOException ex) { LOG.warning("writePtyAnswer ARROW-digit write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -1313,7 +1338,7 @@ public void writePtyAnswer(String answer) { 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(); } } @@ -1335,10 +1360,10 @@ 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"); + writePty("\r"); } catch (IOException ex) { LOG.warning(threadName + " write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -1402,7 +1427,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); @@ -1609,7 +1634,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'}); } // ------------------------------------------------------------------------- @@ -1634,7 +1659,7 @@ public void switchModel(int index) { String customId = customModelIds.get(index - standardModelCount); Thread t = new Thread(() -> { try { - connector.write("/model " + customId + "\r"); + writePty("/model " + customId + "\r"); Thread.sleep(500); } catch (IOException | InterruptedException ex) { LOG.warning("model switch failed: " + ex.getMessage()); @@ -1651,7 +1676,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(); @@ -1659,13 +1684,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); @@ -1748,9 +1773,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"); } } @@ -1808,7 +1833,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. @@ -2244,8 +2269,8 @@ private void showChoiceMenu(ChoiceMenuModel menu) { private MenuEnumerator newMenuEnumerator() { return new MenuEnumerator(screenContentDetector, screenLines, new MenuEnumerator.Keys() { - @Override public void down() throws IOException { connector.write(PTY_ARROW_DOWN); } - @Override public void up() throws IOException { connector.write(PTY_ARROW_UP); } + @Override public void down() throws IOException { writePty(PTY_ARROW_DOWN); } + @Override public void up() throws IOException { writePty(PTY_ARROW_UP); } }, Thread::sleep, MENU_ENUMERATION_SETTLE_MS); } @@ -2425,11 +2450,11 @@ private static boolean isSeparatorLine(String line) { * command in the input), wait, then \r to execute. */ private void openModelMenu() throws IOException, InterruptedException { - connector.write("/model"); + writePty("/model"); Thread.sleep(200); - connector.write(PTY_ESC); + writePty(PTY_ESC); Thread.sleep(200); - connector.write("\r"); + writePty("\r"); Thread.sleep(200); } @@ -2438,10 +2463,39 @@ private void openModelMenu() throws IOException, InterruptedException { * {@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 { + boolean previous = pluginPtyWrite.get(); + pluginPtyWrite.set(true); + try { + connector.write(bytes); + } finally { + pluginPtyWrite.set(previous); + } + } + + /** Writes text to the PTY on behalf of the plugin. See {@link #writePty(byte[])}. */ + private void writePty(String text) throws IOException { + writePty(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + /** Package-private for tests: simulates typing in the terminal at {@code millis}. */ + void simulateExternalInput(long millis) { + externalInputAt = 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/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index dc1c35a7..c37971e9 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -252,7 +252,7 @@ public static boolean containsUserPromptSince(Path workingDir, Path claudeConfig } } - /** Returns whether the JSONL {@code line} is a user message with {@code prompt}. */ + /** 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 { @@ -263,15 +263,17 @@ private static boolean isUserMessageWith(String line, long sinceMillis, String p 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 content.asText().contains(prompt); + if (content.isTextual()) return isSamePrompt(content.asText(), prompt); if (content.isArray()) { + StringBuilder joined = new StringBuilder(); for (JsonNode block : content) { - if (block.isTextual() && block.asText().contains(prompt)) return true; - if (block.isObject() && "text".equals(block.path("type").asText()) - && block.path("text").asText().contains(prompt)) { - return true; - } + String text = textOfBlock(block); + if (text == null) continue; + if (isSamePrompt(text, prompt)) return true; + if (joined.length() > 0) joined.append('\n'); + joined.append(text); } + return isSamePrompt(joined.toString(), prompt); } return false; } catch (Exception e) { @@ -279,6 +281,24 @@ private static boolean isUserMessageWith(String line, long sinceMillis, String p } } + /** Returns the text of a content block, or {@code null} for non-text blocks. */ + private static String textOfBlock(JsonNode block) { + if (block.isTextual()) return block.asText(); + if (block.isObject() && "text".equals(block.path("type").asText())) { + return block.path("text").asText(); + } + return null; + } + + /** + * Compares a stored message with the submitted prompt. The whole message + * must match: a shorter prompt such as {@code yes} must not be treated as + * evidence for an unrelated message that merely contains it. + */ + private static boolean isSamePrompt(String text, String prompt) { + return !text.isBlank() && text.trim().equals(prompt); + } + private static Path newestSessionFile(Path dir, Set excludeIds, long minMtimeMillis) { if (!Files.isDirectory(dir)) { return null; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java b/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java index 7a870694..104ecf1d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java @@ -42,6 +42,9 @@ public final class PtyTtyConnector implements TtyConnector { /** Invoked with each stripped line; {@code null} means no listener. */ private volatile Consumer lineListener; + /** Invoked with every byte block written to the PTY; {@code null} means no listener. */ + private volatile java.util.function.Consumer inputListener; + /** * Creates a connector for the given PTY process. * @@ -64,6 +67,16 @@ public void setLineListener(Consumer listener) { this.lineListener = listener; } + /** + * Sets a listener that receives every block of bytes written to the PTY — + * both plugin-sent input and text typed in the terminal. + * + * @param listener the listener, or {@code null} to clear + */ + public void setInputListener(java.util.function.Consumer listener) { + this.inputListener = listener; + } + /** * Sets a session tag used as a log prefix for debugging. * @@ -109,6 +122,8 @@ public int read(char[] buf, int offset, int length) throws IOException { public void write(byte[] bytes) throws IOException { process.getOutputStream().write(bytes); process.getOutputStream().flush(); + java.util.function.Consumer l = inputListener; + if (l != null) l.accept(bytes); } @Override diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index c151d0b2..71000c54 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -878,7 +878,7 @@ private void startProcessAsync(File dir, String profileName, String extraCliArgs ClaudeSessionController.prepareLaunchIdentity( dir, profileName, mode, resumeSessionId); SwingUtilities.invokeLater(() -> { - if (request != startRequestSeq || tabClosed) return; + if (request != startRequestSeq) return; try { controller.startProcess(dir, profileName, extraCliArgs, mode, resumeSessionId, identity, widget); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 4b7bbfe0..a3af16ee 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1987,6 +1987,47 @@ void promptEvidenceWinsOverANewerUnclaimedFile() throws Exception { } } + /** + * After a plugin prompt, the user can resume another conversation from the + * TUI and type its next prompt directly in the terminal, bypassing + * sendPrompt(). The earlier prompt no longer describes the conversation on + * screen, so the file times must decide again. + */ + @Test + void terminalInputAfterThePromptInvalidatesTheEvidence() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulatePromptText("plugin prompt"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + appendUserPrompt(dir, "a", "plugin prompt", START_MS + 10_500L); + controller.simulateLiveSession(config, "a", Set.of("a")); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_500L); + + // The user types in the terminal: the process moves to "b". + controller.simulateExternalInput(START_MS + 11_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "input typed after the prompt makes the old prompt stale evidence"); + + controller.stopAndRename("On Screen"); + assertEquals("On Screen", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the entered name must follow the conversation on screen"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation left behind must not be renamed"); + } + // ------------------------------------------------------------------------- // prepareLaunchIdentity — captured before the process starts // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 379e39ec..904a4344 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -352,6 +352,23 @@ void containsUserPromptSince_acceptsContentBlocks() throws IOException { OLD_TS.toEpochMilli(), "prompt in a block")); } + @Test + void containsUserPromptSince_requiresTheWholePrompt() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, userLine("Summarize yesterday's changes", OLD_TS.plusSeconds(60))); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "yes"), + "a message that merely contains the prompt is not evidence"); + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "yesterday"), + "a message that merely contains the prompt is not evidence"); + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "Summarize yesterday's changes")); + } + @Test void containsUserPromptSince_noEvidence_returnsFalse() throws IOException { Path workDir = tmpDir.resolve("myproject"); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java index 8f7399cc..cf3ef276 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java @@ -77,6 +77,30 @@ void testGetName() throws Exception { p.waitFor(); } + /** + * Verifies that every block written to the PTY reaches the input listener, + * which the controller uses to notice text typed in the terminal. + */ + @Test + void testInputListenerReceivesWrittenBytes() throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + + PtyProcess p = startCat(); + List received = new ArrayList<>(); + PtyTtyConnector c = new PtyTtyConnector(p); + c.setInputListener(bytes -> received.add(new String(bytes, StandardCharsets.UTF_8))); + + c.write("first"); + c.write(new byte[]{'\r'}); + // No waitFor() here: pty4j 0.12.4 never reports the exit of a child + // whose PTY output was read through an InputStream (as the connector + // does), even though the child itself terminates. + c.close(); + + assertEquals(List.of("first", "\r"), received); + } + /** * Verifies that the line listener receives lines with ANSI escape codes stripped. * From 27c6931d4f53f5b3909059717a67c68704f919c1 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 21:39:54 +0300 Subject: [PATCH 019/111] Kept typed prompts during startup and made session getters null-safe The launch is prepared off the EDT, so both input surfaces are exposed before the PTY connector exists. Two failures were possible in that window: - Pressing the send key cleared the input area, but sendPrompt() returns early while the connector is null, so the prompt was lost. The prompt panel now asks an availability predicate (wired to the controller's canSendPrompt()) and leaves the text in place when it cannot be delivered. - Opening the context menu asked for the OpenAI proxy UUID, and the getter dereferenced the still-null process, throwing on the EDT. The session getters now return null when no process exists. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 25 ++++++++++++++++--- .../claudecodegui/ui/ClaudePromptPanel.java | 22 ++++++++++++++++ .../claudecodegui/ui/ClaudeSessionTab.java | 3 +++ .../ClaudeSessionControllerTest.java | 16 ++++++++++++ .../ui/ClaudePromptPanelSendTest.java | 17 +++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 98a493b8..1e9c82e1 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -579,15 +579,34 @@ 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() { + ClaudeProcess p = claudeProcess; + return p != null ? p.getOpenAIProxyUuid() : null; + } + + /** + * 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 String getOpenAIProxyUuid() { return claudeProcess.getOpenAIProxyUuid(); } + public boolean canSendPrompt() { return connector != null; } public void stopProcess() { if (statusTimer != null) { diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java index bb943274..42797d95 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanel.java @@ -84,6 +84,12 @@ public final class ClaudePromptPanel extends JPanel { private final Runnable onShowSessionStatistics; /** Returns whether Session Statistics is available for the current session's connection type. */ private final Supplier sessionStatisticsAvailable; + /** + * Returns whether a prompt can be delivered right now. The send key must + * respect it: while a session is still starting, {@code onSend} would drop + * the text, so the field keeps it instead. + */ + private volatile Supplier sendAvailable = () -> true; /** Current position in the history list; {@code -1} = newest (empty field). */ private int historyIndex = -1; @@ -243,6 +249,19 @@ public void setReadyState(boolean ready) { cancelButton.setEnabled(!ready); } + /** + * Sets the predicate that decides whether a sent prompt can be delivered. + * + *

When it returns {@code false}, {@code doSend()} leaves the input text + * untouched instead of clearing it for a prompt that would be dropped — + * e.g. while the session process is still starting. + * + * @param sendAvailable the availability predicate, or {@code null} to always allow + */ + public void setSendAvailable(Supplier sendAvailable) { + this.sendAvailable = sendAvailable != null ? sendAvailable : () -> true; + } + /** * Requests keyboard focus on the text input area. */ @@ -321,6 +340,9 @@ public void keyPressed(KeyEvent e) { private void doSend() { String text = inputArea.getText(); if (text.isEmpty()) return; + // Nothing can consume the prompt yet (e.g. the session is still + // starting): keep the text so the user does not lose it. + if (!Boolean.TRUE.equals(sendAvailable.get())) return; inputArea.setText(""); inputArea.requestFocusInWindow(); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 71000c54..446b330f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -355,6 +355,9 @@ public Dimension getMinimumSize() { () -> 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()); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index a3af16ee..f2db4ed9 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2110,6 +2110,22 @@ void stopAndRename_blankName_doesNotScanTheStore() throws Exception { .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. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java index 93722402..2479c004 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudePromptPanelSendTest.java @@ -221,6 +221,23 @@ void emptyTextDoesNotFire() throws Exception { assertNull(sentText.get(), "onSend must NOT be called when text is empty"); } + /** + * While the session is still starting, the send key must not clear the field: + * the prompt could not be delivered and would be lost. + */ + @Test + void sendUnavailableKeepsTheTypedPrompt() throws Exception { + buildPanel(tempDir.getAbsolutePath()); + SwingUtilities.invokeAndWait(() -> panel.setSendAvailable(() -> false)); + setInputText("typed while starting"); + + callDoSend(); + + assertNull(sentText.get(), "onSend must not fire while sending is unavailable"); + assertEquals("typed while starting", getInputText(), + "the typed prompt must be kept for when the session is ready"); + } + @Test void afterSendInputAreaIsCleared() throws Exception { buildPanel(tempDir.getAbsolutePath()); From bcf7512d2a701d77f0fd2c4c4dc9ee868f5d633a Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 21:57:43 +0300 Subject: [PATCH 020/111] Cancelled pending launches when the chat layout is torn down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launch identity is prepared off the EDT, and the process is started on the EDT only if startRequestSeq is unchanged. Stopping the session or choosing "Start New Session" removes the terminal and shows the selector, but did not bump that sequence, so during a slow first launch the process was started afterwards and attached to the detached widget. cleanupChatLayout() — the single teardown point used by stopProcess() and onSaveAndSwitch() — now invalidates any pending launch. Co-Authored-By: Claude Code --- .../claudecodegui/ui/ClaudeSessionTab.java | 3 ++ .../ui/ClaudeSessionTabLaunchCancelTest.java | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabLaunchCancelTest.java diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 446b330f..e09b4ef3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -1352,6 +1352,9 @@ 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++; if (terminalWidget != null) { terminalWidget.close(); terminalWidget = null; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabLaunchCancelTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabLaunchCancelTest.java new file mode 100644 index 00000000..481dac20 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabLaunchCancelTest.java @@ -0,0 +1,39 @@ +package io.github.nbplugins.claudecodegui.ui; + +import io.github.nbplugins.claudecodegui.model.SessionMode; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies that tearing down the chat invalidates a launch that is still being + * prepared in the background. + * + *

{@code startProcessAsync} prepares the launch identity off the EDT and then + * starts the process on the EDT, guarded by {@code startRequestSeq}. Any action + * that tears the chat down — including "Start New Session", which stops and + * clears the layout but may not schedule a replacement start yet — must bump + * that sequence, otherwise the process would be attached to the detached widget. + */ +class ClaudeSessionTabLaunchCancelTest { + + @Test + void saveAndSwitchToNewSession_invalidatesAPendingLaunch() throws Exception { + ClaudeSessionTab tab = new ClaudeSessionTab(); + long before = startRequestSeq(tab); + + // No working directory is known during a launch, so this path only tears + // the chat down and does not schedule a replacement start. + tab.onSaveAndSwitch("", SessionMode.NEW, null); + + assertTrue(startRequestSeq(tab) > before, + "tearing down the chat must cancel a launch still being prepared"); + } + + private static long startRequestSeq(ClaudeSessionTab tab) throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField("startRequestSeq"); + f.setAccessible(true); + return f.getLong(tab); + } +} From d5b73abaf5a1069eede9d595149f48d2518a973f Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 22:18:56 +0300 Subject: [PATCH 021/111] Made prompt evidence prefer the tracked session and survive long turns The prompt written to a JSONL file is what proves a tab has joined another tab's conversation; three weaknesses made it inconclusive: - When two tabs submit the same text (e.g. "continue"), both files match, and the sibling picked first by the candidate set replaced an already correct identity. The session the tab tracks is now checked first, and only then the other live tabs' sessions. - A resume driven entirely from the keyboard recorded only external input, which invalidated the plugin prompt without providing new evidence, so the switch could never be followed. Terminal input is now parsed: a line submitted with Enter becomes the prompt evidence (escape sequences, backspace and Ctrl+C/Ctrl+U are handled), and the evidence check accepts it in the same millisecond as the input. - Only the last 512 KiB of the file were inspected, so a long assistant turn pushed the prompt out of the window. The file is now scanned backwards block by block, stopping at the first record older than the cutoff. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 122 ++++++++++++++++-- .../process/ClaudeSessionStore.java | 89 +++++++++---- .../ClaudeSessionControllerTest.java | 87 +++++++++++++ .../process/ClaudeSessionStoreTest.java | 22 ++++ 4 files changed, 286 insertions(+), 34 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 1e9c82e1..bbdbdeda 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -197,10 +197,11 @@ private record SessionKey(java.nio.file.Path configDir, private volatile long promptSubmittedAt = 0; /** - * Text of the last prompt submitted from the plugin. Together with - * {@link #promptSubmittedAt} it is the evidence that a file written after - * the prompt contains this tab's own messages — e.g. a conversation resumed - * in-process — rather than a concurrent turn of a sibling tab. + * Text of the last prompt submitted from the plugin or typed in the + * terminal. Together with {@link #promptSubmittedAt} it is the evidence that + * a file written after the prompt contains this tab's own messages — e.g. a + * conversation resumed in-process — rather than a concurrent turn of a + * sibling tab. */ private volatile String lastPromptText; @@ -218,6 +219,16 @@ private record SessionKey(java.nio.file.Path configDir, */ private volatile long externalInputAt; + /** + * 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(); + + /** True while an escape sequence in the terminal input is being skipped. */ + private boolean terminalInputEscape; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -515,7 +526,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr connector.setSessionTag(tag); connector.setInputListener(bytes -> { if (Boolean.TRUE.equals(pluginPtyWrite.get())) return; - externalInputAt = System.currentTimeMillis(); + onTerminalInput(bytes, System.currentTimeMillis()); }); screenContentDetector.setSessionTag(tag); @@ -637,6 +648,10 @@ public void stopProcess() { promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; + synchronized (terminalInputLine) { + terminalInputLine.setLength(0); + terminalInputEscape = false; + } identityPollTicks = 0; LIVE_SESSIONS.remove(this); releaseAllClaims(); @@ -782,11 +797,14 @@ boolean refreshSessionIdentity() { // Text typed in the terminal after that prompt may have switched the // conversation through the TUI, in which case the old prompt is no // longer evidence of what is on screen: fall back to file times. - if (promptAt > 0 && promptAt > externalAt) { + // A line submitted from the terminal itself counts as the latest + // prompt (same millisecond), so a resume driven entirely from the + // keyboard is still followed. + if (promptAt > 0 && promptAt >= externalAt) { Set candidates = new HashSet<>(claimed); if (activeId != null) candidates.add(activeId); newest = findPromptBearingSession(dir, configDir, candidates, - promptAt, promptText); + activeId, promptAt, promptText); } if (newest == null) { newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( @@ -820,20 +838,37 @@ boolean refreshSessionIdentity() { * 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} */ private String findPromptBearingSession(java.io.File dir, java.nio.file.Path configDir, - Set candidates, long promptAt, - String promptText) { + Set candidates, String preferred, + long promptAt, String promptText) { if (candidates.isEmpty() || promptText == null || promptText.isBlank()) return null; + if (preferred != null && containsPrompt(dir, configDir, preferred, promptAt, promptText)) { + return preferred; + } for (String sessionId : candidates) { - if (ClaudeSessionStore.containsUserPromptSince( - dir.toPath(), configDir, sessionId, promptAt, promptText)) { + if (!sessionId.equals(preferred) + && containsPrompt(dir, configDir, sessionId, promptAt, promptText)) { return sessionId; } } return null; } + /** Returns whether {@code sessionId} holds {@code promptText} since {@code promptAt}. */ + private static boolean containsPrompt(java.io.File dir, java.nio.file.Path configDir, + String sessionId, long promptAt, String promptText) { + return ClaudeSessionStore.containsUserPromptSince( + dir.toPath(), configDir, sessionId, promptAt, promptText); + } + /** * Publishes a scan result, unless it was superseded: a scan started before a * process restart describes a store the current run does not use, and a scan @@ -2510,11 +2545,76 @@ private void writePty(String text) throws IOException { writePty(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); } + /** + * Records input typed in the terminal (called from the PTY connector, on + * the thread that wrote to the PTY). + * + *

Any such input invalidates the prompt evidence captured before it: the + * text may drive Claude's own {@code /resume} picker and switch the + * conversation. A complete line submitted with Enter is the exception — it + * is a prompt written to the conversation on screen, so it becomes the new + * evidence, which is what lets a resume driven entirely from the keyboard + * be followed. + * + * @param bytes raw input bytes + * @param now time of the input (epoch millis) + */ + private void onTerminalInput(byte[] bytes, long now) { + String submitted; + synchronized (terminalInputLine) { + externalInputAt = now; + submitted = appendTerminalInput(bytes); + } + if (submitted != null) { + lastPromptText = submitted; + promptSubmittedAt = now; + scheduleIdentityRefresh(); + } + } + + /** + * 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. + */ + private String appendTerminalInput(byte[] bytes) { + String submitted = null; + String text = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == 0x1b) { + terminalInputEscape = true; + } else if (terminalInputEscape) { + if (c >= 0x40 && c <= 0x7e) terminalInputEscape = false; + } else if (c == '\r' || c == '\n') { + String line = terminalInputLine.toString().trim(); + terminalInputLine.setLength(0); + if (!line.isEmpty()) submitted = line; + } else if (c == 0x7f || c == 0x08) { + if (terminalInputLine.length() > 0) { + terminalInputLine.setLength(terminalInputLine.length() - 1); + } + } else if (c == 0x03 || c == 0x15) { // Ctrl+C, Ctrl+U + terminalInputLine.setLength(0); + } else if (c >= 0x20) { + terminalInputLine.append(c); + } + } + return submitted; + } + /** 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}. */ + void simulateTerminalInput(byte[] bytes, long millis) { + 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/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index c37971e9..4efe3ab3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -33,12 +33,12 @@ public final class ClaudeSessionStore { private static final ObjectMapper MAPPER = new ObjectMapper(); /** - * How much of a session file is inspected when looking for the prompt a - * running process just appended. The evidence is written within a second of - * the prompt, so the tail is enough and a long conversation does not have to - * be read in full on every identity scan. + * Size of the block read at a time when scanning a session file backwards + * for the prompt a running process just appended. The scan stops at the + * first record older than the cutoff, so the whole file is read only when + * the conversation is entirely newer than it. */ - private static final int PROMPT_EVIDENCE_TAIL_BYTES = 512 * 1024; + private static final int PROMPT_EVIDENCE_BLOCK_BYTES = 512 * 1024; private ClaudeSessionStore() {} @@ -226,24 +226,42 @@ public static boolean containsUserPromptSince(Path workingDir, Path claudeConfig if (!Files.isRegularFile(file)) return false; String wanted = prompt.trim(); if (wanted.isEmpty()) return false; - try { - long size = Files.size(file); - int tailSize = (int) Math.min(size, PROMPT_EVIDENCE_TAIL_BYTES); - byte[] tail = new byte[tailSize]; - try (java.io.RandomAccessFile raf = - new java.io.RandomAccessFile(file.toFile(), "r")) { - raf.seek(size - tailSize); - raf.readFully(tail); - } - String text = new String(tail, StandardCharsets.UTF_8); - if (tailSize < size) { - // The first line is cut in the middle; it belongs to history. - int firstNewline = text.indexOf('\n'); - if (firstNewline < 0) return false; - text = text.substring(firstNewline + 1); - } - for (String line : text.split("\n")) { - if (isUserMessageWith(line, sinceMillis, wanted)) return true; + try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(file.toFile(), "r")) { + // Read backwards in blocks: the newest records are the most likely + // match, and a long turn can push the prompt arbitrarily far from + // the end of the file. The scan stops as soon as a record older + // than the cutoff is reached, since records are appended in time + // order. + long pos = raf.length(); + byte[] carry = new byte[0]; // tail of the line cut at the block start + while (pos > 0) { + int size = (int) Math.min(PROMPT_EVIDENCE_BLOCK_BYTES, pos); + long start = pos - size; + byte[] block = new byte[size + carry.length]; + raf.seek(start); + raf.readFully(block, 0, size); + System.arraycopy(carry, 0, block, size, carry.length); + pos = start; + + int firstLine = 0; + if (pos > 0) { + // The first line is cut in the middle: its beginning is in + // the block read next, so keep its bytes for that block. + int newline = indexOf(block, (byte) '\n'); + if (newline < 0) { + carry = block; + continue; + } + carry = java.util.Arrays.copyOf(block, newline); + firstLine = newline + 1; + } + String text = new String(block, firstLine, block.length - firstLine, + StandardCharsets.UTF_8); + String[] lines = text.split("\n", -1); + for (int i = lines.length - 1; i >= 0; i--) { + if (isUserMessageWith(lines[i], sinceMillis, wanted)) return true; + if (isOlderThan(lines[i], sinceMillis)) return false; + } } return false; } catch (IOException | RuntimeException e) { @@ -252,6 +270,31 @@ public static boolean containsUserPromptSince(Path workingDir, Path claudeConfig } } + /** Returns the index of the first occurrence of {@code b} in {@code bytes}, or {@code -1}. */ + private static int indexOf(byte[] bytes, byte b) { + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] == b) return i; + } + return -1; + } + + /** + * Returns whether the JSONL {@code line} is a record dated before + * {@code cutoffMillis}. 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 carrying {@code prompt}. */ private static boolean isUserMessageWith(String line, long sinceMillis, String prompt) { if (line.isBlank()) return false; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index f2db4ed9..350a36c2 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2028,6 +2028,93 @@ void terminalInputAfterThePromptInvalidatesTheEvidence() throws Exception { "the conversation left behind must not be renamed"); } + /** + * Two tabs in the same store can submit the same prompt text ({@code continue}). + * Then both files hold "this tab's prompt", so the session the tab already + * tracks must win over a sibling's file that happens to be scanned first. + */ + @Test + void promptEvidencePrefersTheTabsOwnSessionWhenPromptsMatch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "sibling", "sibling"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("sibling.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "sibling")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "sibling", Set.of("own", "sibling")); + + try { + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + // Both conversations received an identical prompt after that moment; + // the sibling's file is even newer. + appendUserPrompt(dir, "own", "continue", START_MS + 10_500L); + appendUserPrompt(dir, "sibling", "continue", START_MS + 10_600L); + setMtime(dir.resolve("own.jsonl"), START_MS + 11_000L); + setMtime(dir.resolve("sibling.jsonl"), START_MS + 11_500L); + + assertEquals("own", controller.resolveActiveSessionId(), + "the session this tab already tracks must win over an identical prompt"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A resume driven entirely from the keyboard: the user picks another tab's + * conversation through the TUI and types the next prompt in the terminal. + * That prompt is evidence for the conversation now on screen, even though it + * was not sent by the plugin. + */ + @Test + void promptTypedInTheTerminalFollowsASharedResume() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + appendUserPrompt(dir, "shared", "typed in terminal", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + controller.simulateTerminalInput( + "typed in terminal\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + + assertEquals("shared", controller.resolveActiveSessionId(), + "a prompt typed in the terminal proves the conversation on screen"); + } finally { + tabB.stopProcess(); + } + } + // ------------------------------------------------------------------------- // prepareLaunchIdentity — captured before the process starts // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 904a4344..e23ec449 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -369,6 +369,28 @@ void containsUserPromptSince_requiresTheWholePrompt() throws IOException { OLD_TS.toEpochMilli(), "Summarize yesterday's changes")); } + @Test + void containsUserPromptSince_findsPromptPushedFarFromTheEnd() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + + StringBuilder sb = new StringBuilder(); + sb.append(userLine("the prompt from this tab", OLD_TS.plusSeconds(60))).append('\n'); + // A long assistant turn pushes the prompt beyond the first block read + // from the end of the file (~600 KiB of records). + String filler = "x".repeat(8 * 1024); + for (int i = 0; i < 80; i++) { + sb.append("{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"") + .append(filler).append("\"},\"timestamp\":\"") + .append(OLD_TS.plusSeconds(61 + i)).append("\"}\n"); + } + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.plusSeconds(30).toEpochMilli(), "the prompt from this tab")); + } + @Test void containsUserPromptSince_noEvidence_returnsFalse() throws IOException { Path workDir = tmpDir.resolve("myproject"); From 7abb649b157927863925232fc948f2a8b33d0ae8 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 22:39:12 +0300 Subject: [PATCH 022/111] Fixed restarts after a pending launch and terminal escape parsing Restart actions (Start New Session, Save & Switch, Resume session) used model.getWorkingDirectory(), which is only updated when the process actually starts. While a launch was still being prepared that value still named the previous project, so Restart Advanced from A to B followed by "Start New Session" launched A again. The tab now records the pending launch directory (and its effective config dir) and uses them until the launch starts; the teardown still cancels the pending launch itself. Terminal input parsing treated '[' as an escape-sequence final byte, so ESC [ D (Left) and ESC [ C (Right) appended 'D' and 'C' to the buffered prompt line, recording e.g. "helloDC" for a submitted "hello" and losing the prompt evidence for the conversation on screen. The parser now consumes complete CSI (ESC [ params final) and SS3 (ESC O x) sequences; a single ESC followed by another character remains a two-character escape. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 43 +++++++++--- .../claudecodegui/ui/ClaudeSessionTab.java | 57 ++++++++++++++-- .../ClaudeSessionControllerTest.java | 25 +++++++ .../ui/ClaudeSessionTabPendingLaunchTest.java | 67 +++++++++++++++++++ 4 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index bbdbdeda..382ed253 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -226,8 +226,16 @@ private record SessionKey(java.nio.file.Path configDir, */ private final StringBuilder terminalInputLine = new StringBuilder(); - /** True while an escape sequence in the terminal input is being skipped. */ - private boolean terminalInputEscape; + /** + * 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; /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -650,7 +658,7 @@ public void stopProcess() { externalInputAt = 0; synchronized (terminalInputLine) { terminalInputLine.setLength(0); - terminalInputEscape = false; + terminalInputEscape = 0; } identityPollTicks = 0; LIVE_SESSIONS.remove(this); @@ -2558,8 +2566,9 @@ private void writePty(String text) throws IOException { * * @param bytes raw input bytes * @param now time of the input (epoch millis) + * @return the line submitted with this input, or {@code null} */ - private void onTerminalInput(byte[] bytes, long now) { + private String onTerminalInput(byte[] bytes, long now) { String submitted; synchronized (terminalInputLine) { externalInputAt = now; @@ -2570,6 +2579,7 @@ private void onTerminalInput(byte[] bytes, long now) { promptSubmittedAt = now; scheduleIdentityRefresh(); } + return submitted; } /** @@ -2585,9 +2595,17 @@ private String appendTerminalInput(byte[] bytes) { for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (c == 0x1b) { - terminalInputEscape = true; - } else if (terminalInputEscape) { - if (c >= 0x40 && c <= 0x7e) terminalInputEscape = false; + terminalInputEscape = 1; + } else if (terminalInputEscape == 1) { + // ESC [ starts a CSI sequence, ESC O an SS3 one; any other + // character ends a two-character escape (ESC x). + terminalInputEscape = c == '[' ? 2 : c == 'O' ? 3 : 0; + } 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; + } else if (terminalInputEscape == 3) { + terminalInputEscape = 0; // SS3: exactly one character after ESC O } else if (c == '\r' || c == '\n') { String line = terminalInputLine.toString().trim(); terminalInputLine.setLength(0); @@ -2610,9 +2628,14 @@ void simulateExternalInput(long millis) { externalInputAt = millis; } - /** Package-private for tests: simulates raw terminal input arriving at {@code millis}. */ - void simulateTerminalInput(byte[] bytes, long millis) { - onTerminalInput(bytes, 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); } /** diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index e09b4ef3..3445f7ef 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -173,6 +173,19 @@ public class ClaudeSessionTab extends TopComponent */ private volatile long startRequestSeq; + /** + * Directory of a launch whose preparation is still running, or {@code null}. + * + *

{@link ClaudeSessionModel#getWorkingDirectory()} is only updated when + * the process actually starts, so while the launch is being prepared it + * still names the previous project. Restart actions offered in that window + * must use this directory instead. + */ + private volatile File pendingLaunchDir; + + /** Effective config dir of a launch still being prepared, or {@code null}. */ + private volatile Path pendingLaunchConfigDir; + // ------------------------------------------------------------------------- // MVC // ------------------------------------------------------------------------- @@ -844,6 +857,10 @@ private void startSession(File dir, String profileName, String extraCliArgs, } } 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); @@ -1269,12 +1286,13 @@ private void showSessionStatistics() { } public void openSwitchDialog(SessionMode initialMode) { - File workingDir = model.getWorkingDirectory(); + File workingDir = currentOrPendingDirectory(); if (workingDir == null) return; // The controller captured the effective config dir and session ID at launch, // so the dialog lists/prefills the same store the running process uses. - Path claudeConfigDir = controller.getActiveConfigDir(); + // While a launch is pending, its config dir is the one the process will use. + Path claudeConfigDir = currentOrPendingConfigDir(); String currentId = controller.resolveActiveSessionId(); SavedSession current = currentId != null ? ClaudeSessionStore.findSession(workingDir.toPath(), claudeConfigDir, currentId) @@ -1316,8 +1334,31 @@ static String prefillSessionName(SavedSession session) { return slug != null && !slug.isBlank() ? slug : ""; } + /** + * 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, + * else {@code null} for the default store + */ + private Path currentOrPendingConfigDir() { + Path active = controller.getActiveConfigDir(); + return active != null ? active : pendingLaunchConfigDir; + } + public void onSaveAndSwitch(String sessionName, SessionMode mode, String resumeId) { - File dir = model.getWorkingDirectory(); + File dir = currentOrPendingDirectory(); String profileName = selectorPanel.getSelectedProfileName(); String extraCliArgs = selectorPanel.getExtraCliArgs(); @@ -1336,10 +1377,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(); @@ -1355,6 +1402,8 @@ 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; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 350a36c2..1af9abfe 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2115,6 +2115,31 @@ void promptTypedInTheTerminalFollowsASharedResume() throws Exception { } } + /** + * 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, '[', 'D'}, START_MS + 1); // Left + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'C'}, START_MS + 2); // Right + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'H'}, START_MS + 3); // Home + controller.simulateTerminalInput( + new byte[]{0x1b, '[', '3', '~'}, START_MS + 4); // Delete + controller.simulateTerminalInput( + new byte[]{0x1b, '[', '1', ';', '5', 'D'}, START_MS + 5); // Ctrl+Left + 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"); + } + // ------------------------------------------------------------------------- // prepareLaunchIdentity — captured before the process starts // ------------------------------------------------------------------------- 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..ed0e6d45 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -0,0 +1,67 @@ +package io.github.nbplugins.claudecodegui.ui; + +import io.github.nbplugins.claudecodegui.model.ClaudeSessionModel; +import io.github.nbplugins.claudecodegui.model.SessionMode; +import java.io.File; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies that restart actions target the project being launched while that + * launch is still being prepared. + * + *

{@code ClaudeSessionModel.getWorkingDirectory()} only changes when the + * process actually starts, so during the off-EDT preparation it still names the + * previous project. A restart requested in that window (e.g. "Start New + * Session" after Restart Advanced switched the tab from project A to B) must + * use B, not A. + */ +class ClaudeSessionTabPendingLaunchTest { + + @Test + void restartAfterAPendingLaunchUsesThatLaunchesDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-test"); + try { + File previous = tmp.resolve("project-a").toFile(); + File pending = tmp.resolve("project-b").toFile(); + AtomicReference restartedIn = new AtomicReference<>(); + + ClaudeSessionTab tab = new ClaudeSessionTab() { + @Override + protected void restartSession(File dir, String profileName, + String extraCliArgs, SessionMode mode, + String resumeId) { + restartedIn.set(dir); + } + }; + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + tab.onSaveAndSwitch("", SessionMode.NEW, null); + + assertEquals(pending, restartedIn.get(), + "a restart must target the project being launched, not the " + + "previous one still held by the model"); + } finally { + tmp.toFile().delete(); + } + } + + private static ClaudeSessionModel modelOf(ClaudeSessionTab tab) throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField("model"); + f.setAccessible(true); + return (ClaudeSessionModel) f.get(tab); + } + + private static void setField(ClaudeSessionTab tab, String name, Object value) + throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField(name); + f.setAccessible(true); + f.set(tab, value); + } +} From 66eea74162491d8e578a024c36808387e0e3e9a4 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 22:52:35 +0300 Subject: [PATCH 023/111] Fixed multiline pastes and closing during a pending launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bracketed paste (ESC [ 200 ~ … ESC [ 201 ~) wraps pasted text in escape sequences. Those markers were consumed, but the paste state was not kept, so every newline of a pasted multiline prompt was treated as a submission and cleared the buffer: only the last pasted line became prompt evidence, and the identity scan could no longer confirm the conversation on screen. The terminal input parser now tracks the paste state, records pasted newlines as part of the prompt (normalising CRLF to one newline) and only submits on Enter after the closing marker. componentClosed() saved pathToRestore from model.getWorkingDirectory(), which still names the previous project while a launch is being prepared. Closing the tab in that window cancelled the launch, but reopening restored the old project. It now captures currentOrPendingDirectory() before teardown. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 61 ++++++++++++++++++- .../claudecodegui/ui/ClaudeSessionTab.java | 4 +- .../ClaudeSessionControllerTest.java | 37 +++++++++++ .../ui/ClaudeSessionTabPendingLaunchTest.java | 30 +++++++++ 4 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 382ed253..f55a640c 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -237,6 +237,19 @@ private record SessionKey(java.nio.file.Path configDir, */ private int terminalInputEscape; + /** Parameters of the CSI sequence being parsed, e.g. {@code 200}. */ + private final StringBuilder terminalEscapeParams = new StringBuilder(); + + /** + * 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; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -659,6 +672,9 @@ public void stopProcess() { synchronized (terminalInputLine) { terminalInputLine.setLength(0); terminalInputEscape = 0; + terminalEscapeParams.setLength(0); + terminalInputPaste = false; + terminalPasteAfterCR = false; } identityPollTicks = 0; LIVE_SESSIONS.remove(this); @@ -2587,7 +2603,9 @@ private String onTerminalInput(byte[] bytes, long now) { * 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. + * characters are dropped so that only typed text is kept. 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; @@ -2596,6 +2614,8 @@ private String appendTerminalInput(byte[] bytes) { char c = text.charAt(i); if (c == 0x1b) { terminalInputEscape = 1; + terminalEscapeParams.setLength(0); + terminalPasteAfterCR = 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). @@ -2603,9 +2623,27 @@ private String appendTerminalInput(byte[] bytes) { } 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; + 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 + } else if (terminalInputPaste) { + // Inside a paste every newline is part of the prompt, not a + // submission of the line typed so far. + if (c == '\r') { + terminalInputLine.append('\n'); + terminalPasteAfterCR = true; + } else if (c == '\n') { + if (!terminalPasteAfterCR) terminalInputLine.append('\n'); + terminalPasteAfterCR = false; + } else if (c >= 0x20) { + terminalInputLine.append(c); + terminalPasteAfterCR = false; + } } else if (c == '\r' || c == '\n') { String line = terminalInputLine.toString().trim(); terminalInputLine.setLength(0); @@ -2623,6 +2661,25 @@ private String appendTerminalInput(byte[] bytes) { return submitted; } + /** + * Handles the final byte of a CSI sequence: the bracketed-paste markers + * {@code ESC [ 200 ~} / {@code ESC [ 201 ~} switch paste recording on and + * off, every other sequence 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 != '~') return; + if ("200".equals(params)) { + terminalInputPaste = true; + terminalPasteAfterCR = false; + } else if ("201".equals(params)) { + terminalInputPaste = false; + terminalPasteAfterCR = false; + } + } + /** Package-private for tests: simulates typing in the terminal at {@code millis}. */ void simulateExternalInput(long millis) { externalInputAt = millis; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 3445f7ef..0785b467 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -484,7 +484,9 @@ 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 */ } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 1af9abfe..87c3a9a1 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2140,6 +2140,43 @@ void escapeSequencesDoNotCorruptATerminalPrompt() { "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)); + } + // ------------------------------------------------------------------------- // prepareLaunchIdentity — captured before the process starts // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java index ed0e6d45..45b056c8 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -52,6 +52,36 @@ protected void restartSession(File dir, String profileName, } } + /** + * Closing the tab while a launch is still being prepared cancels that + * launch, but reopening the tab must restore the project it was going to + * use — not the previous one still held by the model. + */ + @Test + void closingWhileAPendingLaunchSavesThatLaunchesDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-close-test"); + try { + File previous = tmp.resolve("project-a").toFile(); + File pending = tmp.resolve("project-b").toFile(); + ClaudeSessionTab tab = new ClaudeSessionTab(); + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + tab.componentClosed(); + + assertEquals(pending.getAbsolutePath(), pathToRestore(tab), + "closing during a launch must restore the project being launched"); + } finally { + tmp.toFile().delete(); + } + } + + private static String pathToRestore(ClaudeSessionTab tab) throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField("pathToRestore"); + f.setAccessible(true); + return (String) f.get(tab); + } + private static ClaudeSessionModel modelOf(ClaudeSessionTab tab) throws Exception { Field f = ClaudeSessionTab.class.getDeclaredField("model"); f.setAccessible(true); From b35687215618c998dd78ae22615d59d0d9dda52c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 23:11:11 +0300 Subject: [PATCH 024/111] Fixed cursor edits in terminal prompts and saving during a pending launch The terminal prompt was reconstructed by appending every typed character, so an edit made with the cursor keys recorded different text from what was submitted: typing "helo", Left and "l" recorded "helol" instead of "hello". The parser now tracks an insertion point and applies Left/Right/Home/End (CSI and SS3 forms), Delete and Backspace at it, and inserts typed and pasted text there. writeExternal() persisted model.getWorkingDirectory(), which still names the previous project while a launch is being prepared, so an IDE save in that window restored the wrong project on the next start. It now uses currentOrPendingDirectory(), like the close path. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 88 +++++++++++++++---- .../claudecodegui/ui/ClaudeSessionTab.java | 4 +- .../ClaudeSessionControllerTest.java | 49 +++++++++-- .../ui/ClaudeSessionTabPendingLaunchTest.java | 31 +++++++ 4 files changed, 149 insertions(+), 23 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index f55a640c..599df135 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -226,6 +226,13 @@ private record SessionKey(java.nio.file.Path configDir, */ 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 [}), @@ -671,6 +678,7 @@ public void stopProcess() { externalInputAt = 0; synchronized (terminalInputLine) { terminalInputLine.setLength(0); + terminalInputCursor = 0; terminalInputEscape = 0; terminalEscapeParams.setLength(0); terminalInputPaste = false; @@ -2603,9 +2611,10 @@ private String onTerminalInput(byte[] bytes, long now) { * 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. Inside a - * bracketed paste the text is recorded as typed — including its newlines, - * which are part of the prompt rather than submissions. + * 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; @@ -2631,52 +2640,97 @@ private String appendTerminalInput(byte[] bytes) { } } 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. if (c == '\r') { - terminalInputLine.append('\n'); + insertTerminalChar('\n'); terminalPasteAfterCR = true; } else if (c == '\n') { - if (!terminalPasteAfterCR) terminalInputLine.append('\n'); + if (!terminalPasteAfterCR) insertTerminalChar('\n'); terminalPasteAfterCR = false; } else if (c >= 0x20) { - terminalInputLine.append(c); + insertTerminalChar(c); terminalPasteAfterCR = false; } } else if (c == '\r' || c == '\n') { String line = terminalInputLine.toString().trim(); terminalInputLine.setLength(0); + terminalInputCursor = 0; if (!line.isEmpty()) submitted = line; } else if (c == 0x7f || c == 0x08) { - if (terminalInputLine.length() > 0) { - terminalInputLine.setLength(terminalInputLine.length() - 1); + if (terminalInputCursor > 0) { + terminalInputLine.deleteCharAt(--terminalInputCursor); } } else if (c == 0x03 || c == 0x15) { // Ctrl+C, Ctrl+U terminalInputLine.setLength(0); + terminalInputCursor = 0; } else if (c >= 0x20) { - terminalInputLine.append(c); + insertTerminalChar(c); } } return submitted; } + /** 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; + } + + /** + * 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. + * + * @param key CSI/SS3 final byte: {@code D}, {@code C}, {@code H} or {@code F} + */ + private void applyCursorKey(char key) { + switch (key) { + case 'D' -> terminalInputCursor = Math.max(0, terminalInputCursor - 1); + case 'C' -> terminalInputCursor = + Math.min(terminalInputLine.length(), terminalInputCursor + 1); + case 'H' -> terminalInputCursor = 0; + case 'F' -> terminalInputCursor = terminalInputLine.length(); + default -> { /* Up/Down and other keys do not edit the line */ } + } + } + + /** Deletes the character under the cursor (the Delete key). */ + private void deleteTerminalCharAtCursor() { + if (terminalInputCursor < terminalInputLine.length()) { + terminalInputLine.deleteCharAt(terminalInputCursor); + } + } + /** * Handles the final byte of a CSI sequence: the bracketed-paste markers * {@code ESC [ 200 ~} / {@code ESC [ 201 ~} switch paste recording on and - * off, every other sequence is dropped. + * 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 != '~') return; - if ("200".equals(params)) { - terminalInputPaste = true; - terminalPasteAfterCR = false; - } else if ("201".equals(params)) { - terminalInputPaste = false; - terminalPasteAfterCR = false; + 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 } } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 0785b467..e3ea0e25 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -564,7 +564,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()); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 87c3a9a1..c985d78a 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2126,13 +2126,13 @@ void escapeSequencesDoNotCorruptATerminalPrompt() { byte[] enter = {'\r'}; assertNull(controller.simulateTerminalInput( "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS)); - controller.simulateTerminalInput(new byte[]{0x1b, '[', 'D'}, START_MS + 1); // Left - controller.simulateTerminalInput(new byte[]{0x1b, '[', 'C'}, START_MS + 2); // Right - controller.simulateTerminalInput(new byte[]{0x1b, '[', 'H'}, START_MS + 3); // Home + 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, '[', '3', '~'}, START_MS + 4); // Delete + new byte[]{0x1b, '[', '5', '~'}, START_MS + 4); // PgUp controller.simulateTerminalInput( - new byte[]{0x1b, '[', '1', ';', '5', 'D'}, START_MS + 5); // Ctrl+Left + 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 @@ -2177,6 +2177,45 @@ void bracketedPasteRecordsCrLfAsASingleNewline() { 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)); + } + // ------------------------------------------------------------------------- // prepareLaunchIdentity — captured before the process starts // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java index 45b056c8..50616cf3 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -76,6 +76,37 @@ void closingWhileAPendingLaunchSavesThatLaunchesDirectory() throws Exception { } } + /** + * NetBeans persists open tabs while a launch may still be preparing; the + * saved path must be the project being launched, not the previous one. + */ + @Test + void persistingDuringAPendingLaunchStoresThatLaunchesDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-save-test"); + try { + File previous = tmp.resolve("project-a").toFile(); + File pending = tmp.resolve("project-b").toFile(); + ClaudeSessionTab tab = new ClaudeSessionTab(); + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + try (java.io.ObjectOutputStream out = new java.io.ObjectOutputStream(bos)) { + tab.writeExternal(out); + } + String serialized = new String(bos.toByteArray(), + java.nio.charset.StandardCharsets.ISO_8859_1); + + // The superclass metadata (tab name/tooltip) may still describe the + // previous project until the launch starts; the restored path is the + // project being launched. + assertTrue(serialized.contains(pending.getAbsolutePath()), + "the saved path must be the project being launched"); + } finally { + tmp.toFile().delete(); + } + } + private static String pathToRestore(ClaudeSessionTab tab) throws Exception { Field f = ClaudeSessionTab.class.getDeclaredField("pathToRestore"); f.setAccessible(true); From 101458a9da428d7634c0f3239366c80080ff8f78 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 23:36:17 +0300 Subject: [PATCH 025/111] Fixed identity evidence for identical prompts and terminal drafts Two live tabs can submit the same prompt text. A NEW tab whose own session file was not written yet adopted the sibling's matching file, and once adopted it pinned itself there: the evidence pass only looked at claimed sessions, so the tab's own file could never win back the identity. The pass is now two-stage: it first looks for the tab's own conversation among the files written since the prompt that no other live tab writes (even if this tab mistakenly claims one), and only then adopts a conversation another live tab is writing, which is what an in-process /resume from the terminal looks like. ClaudeSessionStore gained listSessionIdsWrittenSince() for that candidate list. Prompt evidence was also dropped by any terminal input, including ordinary draft typing. After submitting a prompt in another tab's conversation, typing the next draft before the periodic scan advanced externalInputAt past promptSubmittedAt and the evidence pass was skipped, so Save & Switch renamed the previous conversation. externalInputAt now tracks line submissions only: Enter can act on the TUI without yielding a recognisable prompt, while draft edits cannot change the conversation. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 88 +++++++++++++++-- .../process/ClaudeSessionStore.java | 50 ++++++++++ .../ClaudeSessionControllerTest.java | 96 +++++++++++++++++++ .../process/ClaudeSessionStoreTest.java | 20 ++++ 4 files changed, 245 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 599df135..40b7d8cc 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -81,6 +81,13 @@ public class ClaudeSessionController { */ private static final long SESSION_MTIME_GRANULARITY_MS = 2_000L; + /** + * Maximum number of session files written since a prompt inspected for the + * tab's own conversation. Reading a JSONL is not free, and the file of the + * running process is always among the most recently written ones. + */ + private static final int PROMPT_EVIDENCE_CANDIDATES = 8; + private final ClaudeSessionModel model; /** @@ -212,10 +219,12 @@ private record SessionKey(java.nio.file.Path configDir, private final ThreadLocal pluginPtyWrite = ThreadLocal.withInitial(() -> false); /** - * Time (epoch millis) of the last PTY input typed in the terminal rather - * than sent by the plugin. Such input can change the conversation (an - * in-process {@code /resume}), so prompt evidence captured before it no - * longer describes the session on screen. + * Time (epoch millis) of the last line submitted in the terminal rather + * than sent by the plugin. Enter can act on the TUI (e.g. choose a session + * in the {@code /resume} picker) without producing a prompt this tab + * recognises, so prompt evidence captured before it no longer describes the + * session on screen. Draft edits do not move it: typing the next prompt + * must not invalidate the evidence of the one already submitted. */ private volatile long externalInputAt; @@ -683,6 +692,7 @@ public void stopProcess() { terminalEscapeParams.setLength(0); terminalInputPaste = false; terminalPasteAfterCR = false; + terminalLineEntered = false; } identityPollTicks = 0; LIVE_SESSIONS.remove(this); @@ -794,6 +804,7 @@ boolean refreshSessionIdentity() { String activeId; Set exclude = new HashSet<>(); Set claimed = new HashSet<>(); + Set otherLiveClaims = new HashSet<>(); synchronized (processOwnershipLock) { generation = processGeneration; scanId = ++scanSequence; @@ -806,6 +817,7 @@ boolean refreshSessionIdentity() { activeId = activeSessionId; if (dir != null && configDir != null && since > 0) { claimed.addAll(claimedByOthers(dir, configDir)); + otherLiveClaims.addAll(claimedByOtherLiveTabs(dir, configDir)); exclude.addAll(claimed); if (activeSessionId == null && promptSubmittedAt <= 0) { // Without a prompt of our own there is nothing that proves a @@ -833,10 +845,31 @@ boolean refreshSessionIdentity() { // prompt (same millisecond), so a resume driven entirely from the // keyboard is still followed. if (promptAt > 0 && promptAt >= externalAt) { - Set candidates = new HashSet<>(claimed); - if (activeId != null) candidates.add(activeId); - newest = findPromptBearingSession(dir, configDir, candidates, - activeId, promptAt, promptText); + // First look for the tab's own conversation: a file nobody else + // claims that already holds this prompt. It must win over a + // sibling's file with the same text, because a NEW tab's own + // file appears a moment after the sibling that submitted the + // same prompt — adopting the sibling then would pin this tab to + // another tab's conversation. + Set ownExclude = new HashSet<>(exclude); + ownExclude.addAll(otherLiveClaims); + Set own = new HashSet<>(ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, since, ownExclude, PROMPT_EVIDENCE_CANDIDATES)); + // A session another live tab writes is not this tab's own, even + // when it is the identity currently tracked (a false adoption). + String ownPreferred = activeId != null && !otherLiveClaims.contains(activeId) + ? activeId : null; + newest = findPromptBearingSession(dir, configDir, own, ownPreferred, + promptAt, promptText); + if (newest == null) { + // Only then may a conversation another live tab writes be + // adopted: that is an in-process /resume from this terminal. + Set borrowed = new HashSet<>(claimed); + borrowed.addAll(otherLiveClaims); + if (activeId != null) borrowed.add(activeId); + newest = findPromptBearingSession(dir, configDir, borrowed, activeId, + promptAt, promptText); + } } if (newest == null) { newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( @@ -1039,6 +1072,33 @@ private void releaseAllClaims() { } } + /** + * Session IDs claimed by another live controller in the same store, even + * when this controller claims them too. A conversation shared with another + * tab is not this tab's own, so its own-prompt evidence must not be taken + * from it; {@link #claimedByOthers} answers the different question of which + * files a scan must not consider at all. + */ + private Set claimedByOtherLiveTabs(java.io.File dir, java.nio.file.Path configDir) { + Set ids = new HashSet<>(); + if (dir == null || configDir == null) return ids; + java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); + java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); + SESSION_CLAIMS.forEach((key, owners) -> { + if (!key.configDir().equals(wantedConfig) + || !key.projectDir().equals(wantedProject)) { + return; + } + for (ClaudeSessionController owner : owners) { + if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { + ids.add(key.sessionId()); + return; + } + } + }); + return ids; + } + /** * Session IDs claimed by other live controllers in the same store. * A session ID is not unique across config dirs (a JSONL can be copied @@ -2594,9 +2654,15 @@ private void writePty(String text) throws IOException { */ private String onTerminalInput(byte[] bytes, long now) { String submitted; + boolean entered; synchronized (terminalInputLine) { - externalInputAt = now; + terminalLineEntered = false; submitted = appendTerminalInput(bytes); + entered = terminalLineEntered; + terminalLineEntered = false; + } + if (entered) { + externalInputAt = now; } if (submitted != null) { lastPromptText = submitted; @@ -2658,6 +2724,7 @@ private String appendTerminalInput(byte[] bytes) { String line = terminalInputLine.toString().trim(); terminalInputLine.setLength(0); terminalInputCursor = 0; + terminalLineEntered = true; if (!line.isEmpty()) submitted = line; } else if (c == 0x7f || c == 0x08) { if (terminalInputCursor > 0) { @@ -2673,6 +2740,9 @@ private String appendTerminalInput(byte[] bytes) { return submitted; } + /** Set when {@link #appendTerminalInput(byte[])} saw Enter, cleared by the caller. */ + private boolean terminalLineEntered; + /** Inserts {@code c} at the cursor and advances it. */ private void insertTerminalChar(char c) { int at = Math.min(terminalInputCursor, terminalInputLine.length()); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 4efe3ab3..6e15f5bb 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -202,6 +202,56 @@ public static String findLatestSessionIdWrittenSince(Path workingDir, Path claud return newest != null ? sessionIdOf(newest) : null; } + /** + * Returns up to {@code limit} IDs of session files modified at or after + * {@code sinceMillis}, newest first, skipping {@code excludeIds}. + * + *

Used to look for this tab's own conversation among the files written + * since its prompt: another session can be newer than the one this process + * is writing, so a single newest-file lookup is not enough. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sinceMillis earliest acceptable file modification time (epoch millis) + * @param excludeIds session IDs to ignore (e.g. owned by other tabs); may be {@code null} + * @param limit maximum number of IDs to return + * @return session IDs, newest first; empty if none match + */ + public static List listSessionIdsWrittenSince(Path workingDir, Path claudeConfigDir, + long sinceMillis, Set excludeIds, + int limit) { + if (limit <= 0) return List.of(); + Path dir = sessionsDir(workingDir, claudeConfigDir); + if (!Files.isDirectory(dir)) return List.of(); + List> found = new ArrayList<>(); + try (Stream files = Files.list(dir)) { + for (Path file : (Iterable) files::iterator) { + String name = file.getFileName().toString(); + if (!name.endsWith(".jsonl")) continue; + String id = sessionIdOf(file); + if (excludeIds != null && excludeIds.contains(id)) continue; + long mtime; + try { + mtime = Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + continue; + } + if (mtime < sinceMillis) continue; + found.add(java.util.Map.entry(mtime, id)); + } + } catch (IOException e) { + LOG.fine("Could not scan sessions in " + dir + ": " + e.getMessage()); + return List.of(); + } + found.sort(java.util.Map.Entry.comparingByKey().reversed()); + List ids = new ArrayList<>(); + for (java.util.Map.Entry entry : found) { + ids.add(entry.getValue()); + if (ids.size() >= limit) break; + } + return ids; + } + /** * Returns whether the session file contains a user message with * {@code prompt} whose timestamp is at or after {@code sinceMillis}. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index c985d78a..cc4690d3 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2115,6 +2115,102 @@ void promptTypedInTheTerminalFollowsASharedResume() throws Exception { } } + /** + * 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(); + } + } + /** * 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index e23ec449..9f6d1175 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -304,6 +304,26 @@ void findLatestSessionIdWrittenSince_picksNewestAndSkipsExcluded() throws IOExce workDir, tmpDir, BASE_MTIME, java.util.Set.of())); } + @Test + void listSessionIdsWrittenSince_returnsNewestFirstAndSkipsExcluded() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "old", OLD_TS, BASE_MTIME); + writeTimedSession(sessionDir, "mid", OLD_TS, BASE_MTIME + 1_000L); + writeTimedSession(sessionDir, "new", OLD_TS, BASE_MTIME + 2_000L); + + assertEquals(java.util.List.of("new", "mid"), + ClaudeSessionStore.listSessionIdsWrittenSince( + workDir, tmpDir, BASE_MTIME + 500L, java.util.Set.of("old"), 8)); + assertEquals(java.util.List.of("new"), + ClaudeSessionStore.listSessionIdsWrittenSince( + workDir, tmpDir, BASE_MTIME + 500L, java.util.Set.of("old"), 1), + "the limit must keep the newest files"); + assertTrue(ClaudeSessionStore.listSessionIdsWrittenSince( + workDir, tmpDir, BASE_MTIME + 5_000L, java.util.Set.of(), 8).isEmpty(), + "files older than the cutoff are not candidates"); + } + // ------------------------------------------------------------------------- // containsUserPromptSince // ------------------------------------------------------------------------- From 9ea37e91ac118dbe7c4899ad6157fa0744146a65 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 09:30:01 +0300 Subject: [PATCH 026/111] Fixed session identity swapping between tabs and late submission timestamps A tab that only follows another live tab's conversation (a NEW tab whose file is still pending, or an in-process resume into a sibling's session) now holds a borrowed claim instead of ownership. Previously that claim made the conversation look foreign to the tab that established it, so when the borrowing tab's own file appeared the owner discarded its correctly tracked session and adopted that file; both tabs then renamed each other's conversations in Save & Switch. Ownership is now tracked separately from a provisional borrow, and only an ownership claim keeps a session out of the owner's own evidence pass. PtyTtyConnector samples the submission timestamp before forwarding bytes to the child and passes it to the input listener. The child can append the prompt record to its session file before the listener runs, so a timestamp sampled in the listener could be later than the record, causing the plugin to reject the prompt it just sent as evidence of the conversation on screen. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 111 ++++++++++++++---- .../process/PtyTtyConnector.java | 32 ++++- .../ClaudeSessionControllerTest.java | 58 +++++++++ .../process/PtyTtyConnectorTest.java | 73 +++++++++++- 4 files changed, 245 insertions(+), 29 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 40b7d8cc..db91f798 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -141,9 +141,24 @@ private record SessionKey(java.nio.file.Path configDir, String sessionId) { } /** Session file → live controllers running exactly that file. */ - private static final Map> SESSION_CLAIMS = + private static final Map SESSION_CLAIMS = new ConcurrentHashMap<>(); + /** + * Live controllers writing one session file, split by how they came to it. + * A controller that only follows a conversation another tab established — + * a NEW tab borrowing it until its own file appears, or an in-process + * resume into a sibling's conversation — holds a + * {@linkplain #borrowed borrowed} claim. A borrowed claim is not ownership: + * it must not make the file look foreign to the tab that established it, + * or that tab would discard its own correctly tracked session and adopt the + * borrower's new file instead. + */ + private static final class SessionClaims { + final Set owners = ConcurrentHashMap.newKeySet(); + final Set borrowed = ConcurrentHashMap.newKeySet(); + } + /** * Worker for the periodic session-store scans, shared by all controllers so * that the Swing EDT never enumerates the session history. @@ -561,9 +576,9 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr connector = new PtyTtyConnector(process); connector.setSessionTag(tag); - connector.setInputListener(bytes -> { + connector.setInputListener((bytes, sentAt) -> { if (Boolean.TRUE.equals(pluginPtyWrite.get())) return; - onTerminalInput(bytes, System.currentTimeMillis()); + onTerminalInput(bytes, sentAt); }); screenContentDetector.setSessionTag(tag); @@ -805,6 +820,7 @@ boolean refreshSessionIdentity() { Set exclude = new HashSet<>(); Set claimed = new HashSet<>(); Set otherLiveClaims = new HashSet<>(); + Set otherOwned = new HashSet<>(); synchronized (processOwnershipLock) { generation = processGeneration; scanId = ++scanSequence; @@ -818,6 +834,7 @@ boolean refreshSessionIdentity() { if (dir != null && configDir != null && since > 0) { claimed.addAll(claimedByOthers(dir, configDir)); otherLiveClaims.addAll(claimedByOtherLiveTabs(dir, configDir)); + otherOwned.addAll(sessionsOwnedByOtherLiveTabs(dir, configDir)); exclude.addAll(claimed); if (activeSessionId == null && promptSubmittedAt <= 0) { // Without a prompt of our own there is nothing that proves a @@ -852,12 +869,15 @@ boolean refreshSessionIdentity() { // same prompt — adopting the sibling then would pin this tab to // another tab's conversation. Set ownExclude = new HashSet<>(exclude); - ownExclude.addAll(otherLiveClaims); + ownExclude.addAll(otherOwned); Set own = new HashSet<>(ClaudeSessionStore.listSessionIdsWrittenSince( dir.toPath(), configDir, since, ownExclude, PROMPT_EVIDENCE_CANDIDATES)); - // A session another live tab writes is not this tab's own, even - // when it is the identity currently tracked (a false adoption). - String ownPreferred = activeId != null && !otherLiveClaims.contains(activeId) + // A session another live tab established is not this tab's own, + // even when it is the identity currently tracked (a false + // adoption). A merely borrowed claim does not count: a sibling + // following this tab's conversation must not cost this tab the + // session it established. + String ownPreferred = activeId != null && !otherOwned.contains(activeId) ? activeId : null; newest = findPromptBearingSession(dir, configDir, own, ownPreferred, promptAt, promptText); @@ -1044,30 +1064,47 @@ private void claimSession(java.io.File dir, java.nio.file.Path configDir, // Membership and empty-entry cleanup stay inside compute() so a claim // cannot be added to an owner set that a concurrent release just // detached from the map (which would silently lose the claim). - SESSION_CLAIMS.compute(key, (k, owners) -> { - Set updated = - owners != null ? owners : ConcurrentHashMap.newKeySet(); - updated.add(this); + SESSION_CLAIMS.compute(key, (k, claims) -> { + SessionClaims updated = claims != null ? claims : new SessionClaims(); + updated.owners.add(this); + if (ownedByAnotherLiveTab(updated)) { + // Another tab established this conversation; this tab is only + // following it until a file of its own takes over. + updated.borrowed.add(this); + } else { + updated.borrowed.remove(this); + } return updated; }); } + /** {@code true} if {@code claims} holds an established claim by another live tab. */ + private boolean ownedByAnotherLiveTab(SessionClaims claims) { + for (ClaudeSessionController owner : claims.owners) { + if (owner == this || claims.borrowed.contains(owner)) continue; + if (LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) return true; + } + return false; + } + private void releaseClaim(java.io.File dir, java.nio.file.Path configDir, String sessionId) { SessionKey key = keyFor(dir, configDir, sessionId); if (key == null) return; - SESSION_CLAIMS.computeIfPresent(key, (k, owners) -> { - owners.remove(this); - return owners.isEmpty() ? null : owners; + SESSION_CLAIMS.computeIfPresent(key, (k, claims) -> { + claims.owners.remove(this); + claims.borrowed.remove(this); + return claims.owners.isEmpty() ? null : claims; }); } /** Removes all claims held by this controller (called when the process stops). */ private void releaseAllClaims() { for (SessionKey key : SESSION_CLAIMS.keySet()) { - SESSION_CLAIMS.computeIfPresent(key, (k, owners) -> { - owners.remove(this); - return owners.isEmpty() ? null : owners; + SESSION_CLAIMS.computeIfPresent(key, (k, claims) -> { + claims.owners.remove(this); + claims.borrowed.remove(this); + return claims.owners.isEmpty() ? null : claims; }); } } @@ -1084,12 +1121,12 @@ private Set claimedByOtherLiveTabs(java.io.File dir, java.nio.file.Path if (dir == null || configDir == null) return ids; java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); - SESSION_CLAIMS.forEach((key, owners) -> { + SESSION_CLAIMS.forEach((key, claims) -> { if (!key.configDir().equals(wantedConfig) || !key.projectDir().equals(wantedProject)) { return; } - for (ClaudeSessionController owner : owners) { + for (ClaudeSessionController owner : claims.owners) { if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { ids.add(key.sessionId()); return; @@ -1099,6 +1136,36 @@ private Set claimedByOtherLiveTabs(java.io.File dir, java.nio.file.Path return ids; } + /** + * Session IDs another live controller has established in the same + * store. A claim that merely follows the conversation (see + * {@link SessionClaims#borrowed}) is not ownership and is skipped: when a + * NEW tab borrows a live tab's conversation until its own file appears, + * that file must still count as the live tab's own, or the live tab would + * discard its correctly tracked session and adopt the borrower's file. + */ + private Set sessionsOwnedByOtherLiveTabs(java.io.File dir, + java.nio.file.Path configDir) { + Set ids = new HashSet<>(); + if (dir == null || configDir == null) return ids; + java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); + java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); + SESSION_CLAIMS.forEach((key, claims) -> { + if (!key.configDir().equals(wantedConfig) + || !key.projectDir().equals(wantedProject)) { + return; + } + for (ClaudeSessionController owner : claims.owners) { + if (owner != this && !claims.borrowed.contains(owner) + && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { + ids.add(key.sessionId()); + return; + } + } + }); + return ids; + } + /** * Session IDs claimed by other live controllers in the same store. * A session ID is not unique across config dirs (a JSONL can be copied @@ -1110,7 +1177,7 @@ private Set claimedByOthers(java.io.File dir, java.nio.file.Path configD if (dir == null || configDir == null) return ids; java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); - SESSION_CLAIMS.forEach((key, owners) -> { + SESSION_CLAIMS.forEach((key, claims) -> { if (!key.configDir().equals(wantedConfig) || !key.projectDir().equals(wantedProject)) { return; @@ -1118,10 +1185,10 @@ private Set claimedByOthers(java.io.File dir, java.nio.file.Path configD // A session shared with another tab (both resumed the same saved // conversation) is still this tab's own conversation: excluding it // would make the scan pick an unrelated file. - if (owners.contains(this)) { + if (claims.owners.contains(this)) { return; } - for (ClaudeSessionController owner : owners) { + for (ClaudeSessionController owner : claims.owners) { if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { ids.add(key.sessionId()); return; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java b/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java index 104ecf1d..5ae4e559 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java @@ -34,6 +34,9 @@ public final class PtyTtyConnector implements TtyConnector { private final PtyProcess process; private final InputStreamReader reader; + /** Time source for the submission timestamps passed to the input listener. */ + private final java.util.function.LongSupplier clock; + private String tag = ""; /** Accumulates raw chars between newlines for line-based detection. */ @@ -42,8 +45,9 @@ public final class PtyTtyConnector implements TtyConnector { /** Invoked with each stripped line; {@code null} means no listener. */ private volatile Consumer lineListener; - /** Invoked with every byte block written to the PTY; {@code null} means no listener. */ - private volatile java.util.function.Consumer inputListener; + /** Invoked with every byte block written to the PTY and the time the write + * started; {@code null} means no listener. */ + private volatile java.util.function.BiConsumer inputListener; /** * Creates a connector for the given PTY process. @@ -51,7 +55,13 @@ public final class PtyTtyConnector implements TtyConnector { * @param process a running PTY process */ public PtyTtyConnector(PtyProcess process) { + this(process, System::currentTimeMillis); + } + + /** Package-private for tests: injects the clock used for terminal submissions. */ + PtyTtyConnector(PtyProcess process, java.util.function.LongSupplier clock) { this.process = process; + this.clock = clock; this.reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8); } @@ -69,11 +79,16 @@ public void setLineListener(Consumer listener) { /** * Sets a listener that receives every block of bytes written to the PTY — - * both plugin-sent input and text typed in the terminal. + * both plugin-sent input and text typed in the terminal — together with the + * time the write started. + * + *

The timestamp is sampled before the bytes are forwarded so that a + * child which appends the submitted prompt to its session file right after + * reading them cannot produce a record later than the reported time. * * @param listener the listener, or {@code null} to clear */ - public void setInputListener(java.util.function.Consumer listener) { + public void setInputListener(java.util.function.BiConsumer listener) { this.inputListener = listener; } @@ -120,10 +135,15 @@ public int read(char[] buf, int offset, int length) throws IOException { @Override public void write(byte[] bytes) throws IOException { + // Sampled before the bytes are forwarded: the child can append the + // submitted prompt to its session file as soon as it reads them, so a + // timestamp taken after the write/listener could be later than the + // record the controller uses as evidence. + long sentAt = clock.getAsLong(); process.getOutputStream().write(bytes); process.getOutputStream().flush(); - java.util.function.Consumer l = inputListener; - if (l != null) l.accept(bytes); + java.util.function.BiConsumer l = inputListener; + if (l != null) l.accept(bytes, sentAt); } @Override diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index cc4690d3..c444d4b5 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2211,6 +2211,64 @@ void newTabWithTheSamePromptAsASibling_takesItsOwnFileWhenItAppears() throws Exc } } + /** + * 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(); + } + } + /** * 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java index cf3ef276..998a2bc8 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java @@ -89,7 +89,8 @@ void testInputListenerReceivesWrittenBytes() throws Exception { PtyProcess p = startCat(); List received = new ArrayList<>(); PtyTtyConnector c = new PtyTtyConnector(p); - c.setInputListener(bytes -> received.add(new String(bytes, StandardCharsets.UTF_8))); + c.setInputListener((bytes, sentAt) -> + received.add(new String(bytes, StandardCharsets.UTF_8))); c.write("first"); c.write(new byte[]{'\r'}); @@ -101,6 +102,76 @@ void testInputListenerReceivesWrittenBytes() throws Exception { assertEquals(List.of("first", "\r"), received); } + /** + * The submission time handed to the input listener must be sampled by the + * connector before the bytes are forwarded, not by the listener + * itself: the child may append the prompt to its session file between the + * write and the listener call (the writing thread can be descheduled after + * the flush). A later timestamp would make the controller reject the very + * prompt it just sent when it looks for evidence of the conversation on + * screen. + */ + @Test + void testSubmissionTimeIsSampledBeforeTheBytesAreDelivered() throws Exception { + RecordingPtyProcess p = new RecordingPtyProcess(); + List times = new ArrayList<>(); + PtyTtyConnector c = new PtyTtyConnector(p, () -> { + if (p.delivered) p.sampledAfterDelivery = true; + return 1_234L; + }); + c.setInputListener((bytes, sentAt) -> times.add(sentAt)); + + c.write("first"); + + assertEquals("first", p.writtenText()); + assertEquals(List.of(1_234L), times); + assertFalse(p.sampledAfterDelivery, + "the submission time must be sampled before the bytes reach the child"); + } + + /** + * A PTY double that records the order of the two events this test cares + * about: the pending input reaching the child and the connector sampling + * the submission time. + */ + private static final class RecordingPtyProcess extends PtyProcess { + private final java.io.ByteArrayOutputStream written = + new java.io.ByteArrayOutputStream(); + private final InputStream output = new ByteArrayInputStream(new byte[0]); + boolean delivered; + boolean sampledAfterDelivery; + + @Override + public OutputStream getOutputStream() { + return new OutputStream() { + @Override + public void write(int b) { + delivered = true; + written.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + delivered = true; + written.write(b, off, len); + } + }; + } + + String writtenText() { + return written.toString(StandardCharsets.UTF_8); + } + + @Override public InputStream getInputStream() { return output; } + @Override public InputStream getErrorStream() { return output; } + @Override public int waitFor() { return 0; } + @Override public int exitValue() { return 0; } + @Override public boolean isAlive() { return true; } + @Override public void destroy() { } + @Override public void setWinSize(com.pty4j.WinSize winSize) { } + @Override public com.pty4j.WinSize getWinSize() { return new com.pty4j.WinSize(80, 24); } + } + /** * Verifies that the line listener receives lines with ANSI escape codes stripped. * From d5d2b035da7a2d11299a690d74258469e0c903a4 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 09:47:27 +0300 Subject: [PATCH 027/111] Fixed Unicode prompt edits and duplicate tabs during a pending launch Backspace, Delete and the horizontal cursor keys now work on whole Unicode characters. A supplementary character such as an emoji occupies two UTF-16 code units; editing one half left an unpaired surrogate in the recorded prompt, which could never match the message actually submitted, so a resume into another live tab's conversation was not recognised and Save & Switch renamed the previous conversation instead. The existing-tab lookup behind "Open with Claude Code" now consults the directory of a launch that is still being prepared, not just the model's working directory (which still names the previous project until the process starts). A second action during that window used to create another tab and start another Claude process for the same project. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 39 ++++++++++++++----- .../claudecodegui/ui/ClaudeSessionTab.java | 17 +++++++- .../ClaudeSessionControllerTest.java | 26 +++++++++++++ .../ui/ClaudeSessionTabPendingLaunchTest.java | 24 ++++++++++++ 4 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index db91f798..cd1de73b 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -2794,9 +2794,7 @@ private String appendTerminalInput(byte[] bytes) { terminalLineEntered = true; if (!line.isEmpty()) submitted = line; } else if (c == 0x7f || c == 0x08) { - if (terminalInputCursor > 0) { - terminalInputLine.deleteCharAt(--terminalInputCursor); - } + deleteTerminalCharBeforeCursor(); } else if (c == 0x03 || c == 0x15) { // Ctrl+C, Ctrl+U terminalInputLine.setLength(0); terminalInputCursor = 0; @@ -2820,25 +2818,48 @@ private void insertTerminalChar(char c) { /** * 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' -> terminalInputCursor = Math.max(0, terminalInputCursor - 1); + case 'D' -> terminalInputCursor = + terminalInputLine.offsetByCodePoints( + Math.min(terminalInputCursor, terminalInputLine.length()), -1); case 'C' -> terminalInputCursor = - Math.min(terminalInputLine.length(), terminalInputCursor + 1); + terminalInputLine.offsetByCodePoints( + Math.min(terminalInputCursor, terminalInputLine.length()), 1); case 'H' -> terminalInputCursor = 0; case 'F' -> terminalInputCursor = terminalInputLine.length(); default -> { /* Up/Down and other keys do not edit the line */ } } } - /** Deletes the character under the cursor (the Delete key). */ + /** + * 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() { - if (terminalInputCursor < terminalInputLine.length()) { - terminalInputLine.deleteCharAt(terminalInputCursor); - } + int at = Math.min(terminalInputCursor, terminalInputLine.length()); + if (at >= terminalInputLine.length()) return; + int end = terminalInputLine.offsetByCodePoints(at, 1); + terminalInputLine.delete(at, end); + terminalInputCursor = at; } /** diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index e3ea0e25..2046949f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -437,8 +437,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; } @@ -1338,6 +1337,20 @@ static String prefillSessionName(SavedSession session) { 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()); + } + /** * Directory the Save & Switch dialog and restart actions operate on: the * launch still being prepared (if any), otherwise the running session's. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index c444d4b5..22e1272d 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2370,6 +2370,32 @@ void homeEndDeleteAndBackspaceEditTheSubmittedPrompt() { 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"); + } + // ------------------------------------------------------------------------- // prepareLaunchIdentity — captured before the process starts // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java index 50616cf3..1b96de62 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -107,6 +107,30 @@ void persistingDuringAPendingLaunchStoresThatLaunchesDirectory() throws Exceptio } } + /** + * Opening the same project again while its launch is still being prepared + * must focus that tab instead of creating a second one: the model still + * reports the previous directory (or none) until the process starts. + */ + @Test + void aTabWithAPendingLaunchServesThatDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-lookup-test"); + try { + File previous = tmp.resolve("project-a").toFile(); + File pending = tmp.resolve("project-b").toFile(); + ClaudeSessionTab tab = new ClaudeSessionTab(); + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + assertTrue(tab.servesDirectory(pending), + "a tab whose launch is pending already serves that directory"); + assertFalse(tab.servesDirectory(previous), + "the previous project must not be treated as served any more"); + } finally { + tmp.toFile().delete(); + } + } + private static String pathToRestore(ClaudeSessionTab tab) throws Exception { Field f = ClaudeSessionTab.class.getDeclaredField("pathToRestore"); f.setAccessible(true); From 46a3f03244618375e02bb64e34c33361f9984c43 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 10:11:34 +0300 Subject: [PATCH 028/111] Fixed provisional session claims, boundary cursor keys and pending prompt paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NEW tab cannot know its session before its first prompt: the file it adopts is a guess. Claims were classified only as owned or borrowed, so the first tab to scan an unclaimed file (holding the same prompt) became its owner and the second was marked a borrower. When the first tab's own file appeared, the borrower moved to it while the "owner" kept preferring the guessed file, swapping the two conversations permanently. Claims now have three kinds: owned (identity known at launch), borrowed (joined a file another live tab already held) and provisional (any other hold, which is only a guess). Only ownership counts when deciding whether a file belongs to another tab, and a provisional holder that shares its file with another live tab no longer prefers it — the scan looks for a file nobody else follows first, so each tab settles on its own conversation. Cursor Left/Right now clamp at the line ends instead of calling offsetByCodePoints out of range, which threw IndexOutOfBoundsException through the input listener from an ordinary keypress. The prompt's working-directory supplier used model.getWorkingDirectory(), which still names the previous project while a launch is being prepared; pasted or dropped file references in that window were stored relative to the wrong project. It now uses currentOrPendingDirectory(). Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 154 +++++++++++++----- .../claudecodegui/ui/ClaudeSessionTab.java | 26 ++- .../ClaudeSessionControllerTest.java | 81 +++++++++ .../ui/ClaudeSessionTabPendingLaunchTest.java | 23 +++ 4 files changed, 234 insertions(+), 50 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index cd1de73b..bd5ff88a 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -145,16 +145,27 @@ private record SessionKey(java.nio.file.Path configDir, new ConcurrentHashMap<>(); /** - * Live controllers writing one session file, split by how they came to it. - * A controller that only follows a conversation another tab established — - * a NEW tab borrowing it until its own file appears, or an in-process - * resume into a sibling's conversation — holds a - * {@linkplain #borrowed borrowed} claim. A borrowed claim is not ownership: - * it must not make the file look foreign to the tab that established it, - * or that tab would discard its own correctly tracked session and adopt the - * borrower's new file instead. + * Live controllers writing one session file, split by how strong their hold + * on it is. + * + *

    + *
  • {@link #owners} — the tab knew this conversation at launch + * ({@code --continue}/{@code --resume}) and no other live tab held it + * when the claim was made. Only an owner may treat the file as + * established, and only ownership elsewhere makes the file foreign to + * this tab.
  • + *
  • {@code provisional} (a holder in neither set) — a {@code NEW} tab + * guessed that this file is its own. Any tab may guess wrong when + * another unresolved tab writes the same prompt, so a provisional + * claim must yield when another live tab follows the same file and a + * file nobody follows holds the prompt.
  • + *
  • {@link #borrowed} — the tab joined a file another live tab already + * held (an in-process resume into a sibling's conversation, or a NEW + * tab following it until its own file appears).
  • + *
*/ private static final class SessionClaims { + final Set holders = ConcurrentHashMap.newKeySet(); final Set owners = ConcurrentHashMap.newKeySet(); final Set borrowed = ConcurrentHashMap.newKeySet(); } @@ -211,6 +222,14 @@ private static final class SessionClaims { */ private volatile Set preStartSessionIds = Set.of(); + /** + * {@code true} when the session ID was known before the first prompt (a + * {@code --continue}/{@code --resume} launch). A {@code NEW} session has no + * identity up front, so the file it later adopts is only a guess until the + * tab proves the file is its own — see {@link #claimSession}. + */ + private volatile boolean launchIdentityKnown; + /** * Time (epoch millis) a prompt was last submitted from the plugin. Used to * attribute the session file created by a new process to this tab promptly, @@ -367,6 +386,7 @@ void simulateActiveSession(java.nio.file.Path configDir, String sessionId, activeConfigDir = configDir; activeSessionId = sessionId; preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); + launchIdentityKnown = sessionId != null; } /** Package-private for tests: one tick of the periodic identity refresh. */ @@ -559,6 +579,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr : prepareLaunchIdentity(dir, profileName, effectiveMode, resumeSessionId); preStartSessionIds = launch.preStartSessionIds(); activeSessionId = launch.sessionId(); + launchIdentityKnown = activeSessionId != null; PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : "", @@ -697,6 +718,7 @@ public void stopProcess() { activeConfigDir = null; activeSessionId = null; preStartSessionIds = Set.of(); + launchIdentityKnown = false; promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; @@ -821,6 +843,7 @@ boolean refreshSessionIdentity() { Set claimed = new HashSet<>(); Set otherLiveClaims = new HashSet<>(); Set otherOwned = new HashSet<>(); + boolean trackedProvisional; synchronized (processOwnershipLock) { generation = processGeneration; scanId = ++scanSequence; @@ -831,6 +854,9 @@ boolean refreshSessionIdentity() { externalAt = externalInputAt; promptText = lastPromptText; activeId = activeSessionId; + // A NEW tab's first guess at its own file is not ownership: another + // unresolved tab may hold the same file as a guess of its own. + trackedProvisional = claimIsProvisional(dir, configDir, activeId); if (dir != null && configDir != null && since > 0) { claimed.addAll(claimedByOthers(dir, configDir)); otherLiveClaims.addAll(claimedByOtherLiveTabs(dir, configDir)); @@ -876,11 +902,26 @@ boolean refreshSessionIdentity() { // even when it is the identity currently tracked (a false // adoption). A merely borrowed claim does not count: a sibling // following this tab's conversation must not cost this tab the - // session it established. + // session it established. A provisional claim (this NEW tab's + // guess) loses the preference when another tab also holds the + // file: until ownership is clear, the tab must not keep the + // guessed file merely because it guessed first. String ownPreferred = activeId != null && !otherOwned.contains(activeId) + && !(trackedProvisional && otherLiveClaims.contains(activeId)) ? activeId : null; - newest = findPromptBearingSession(dir, configDir, own, ownPreferred, - promptAt, promptText); + if (ownPreferred == null) { + // Prefer the conversation no other live tab holds: with two + // initially unresolved NEW tabs, that is the only file that + // can be this tab's own. + Set unshared = new HashSet<>(own); + unshared.removeAll(otherLiveClaims); + newest = findPromptBearingSession(dir, configDir, unshared, null, + promptAt, promptText); + } + if (newest == null) { + newest = findPromptBearingSession(dir, configDir, own, ownPreferred, + promptAt, promptText); + } if (newest == null) { // Only then may a conversation another live tab writes be // adopted: that is an in-process /resume from this terminal. @@ -1066,35 +1107,55 @@ private void claimSession(java.io.File dir, java.nio.file.Path configDir, // detached from the map (which would silently lose the claim). SESSION_CLAIMS.compute(key, (k, claims) -> { SessionClaims updated = claims != null ? claims : new SessionClaims(); - updated.owners.add(this); - if (ownedByAnotherLiveTab(updated)) { - // Another tab established this conversation; this tab is only - // following it until a file of its own takes over. - updated.borrowed.add(this); - } else { - updated.borrowed.remove(this); + if (!updated.holders.contains(this)) { + // Classify only a new hold: re-claiming the identity on every + // scan must not turn an owner into a borrower of its own file. + if (heldByAnotherLiveTab(updated)) { + // Another tab is already writing this conversation; this + // tab is only following it. + updated.borrowed.add(this); + } else if (launchIdentityKnown) { + updated.owners.add(this); + } + // Otherwise the claim stays provisional: this NEW tab guessed + // that the file is its own. + updated.holders.add(this); } return updated; }); } - /** {@code true} if {@code claims} holds an established claim by another live tab. */ - private boolean ownedByAnotherLiveTab(SessionClaims claims) { - for (ClaudeSessionController owner : claims.owners) { - if (owner == this || claims.borrowed.contains(owner)) continue; - if (LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) return true; + /** {@code true} if another live tab already holds {@code claims}. */ + private boolean heldByAnotherLiveTab(SessionClaims claims) { + for (ClaudeSessionController holder : claims.holders) { + if (holder != this && LIVE_SESSIONS.contains(holder) && holder.isProcessLive()) { + return true; + } } return false; } + /** + * {@code true} if this controller holds {@code sessionId} only as a guess + * for a {@code NEW} session: it neither established the file at launch nor + * joined a tab that already held it. + */ + private boolean claimIsProvisional(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + SessionKey key = keyFor(dir, configDir, sessionId); + if (key == null) return false; + SessionClaims claims = SESSION_CLAIMS.get(key); + return claims != null && claims.holders.contains(this) + && !claims.owners.contains(this) && !claims.borrowed.contains(this); + } + private void releaseClaim(java.io.File dir, java.nio.file.Path configDir, String sessionId) { SessionKey key = keyFor(dir, configDir, sessionId); if (key == null) return; SESSION_CLAIMS.computeIfPresent(key, (k, claims) -> { - claims.owners.remove(this); - claims.borrowed.remove(this); - return claims.owners.isEmpty() ? null : claims; + releaseFromClaims(claims); + return claims.holders.isEmpty() ? null : claims; }); } @@ -1102,13 +1163,18 @@ private void releaseClaim(java.io.File dir, java.nio.file.Path configDir, private void releaseAllClaims() { for (SessionKey key : SESSION_CLAIMS.keySet()) { SESSION_CLAIMS.computeIfPresent(key, (k, claims) -> { - claims.owners.remove(this); - claims.borrowed.remove(this); - return claims.owners.isEmpty() ? null : claims; + releaseFromClaims(claims); + return claims.holders.isEmpty() ? null : claims; }); } } + private void releaseFromClaims(SessionClaims claims) { + claims.holders.remove(this); + claims.owners.remove(this); + claims.borrowed.remove(this); + } + /** * Session IDs claimed by another live controller in the same store, even * when this controller claims them too. A conversation shared with another @@ -1126,8 +1192,8 @@ private Set claimedByOtherLiveTabs(java.io.File dir, java.nio.file.Path || !key.projectDir().equals(wantedProject)) { return; } - for (ClaudeSessionController owner : claims.owners) { - if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { + for (ClaudeSessionController holder : claims.holders) { + if (holder != this && LIVE_SESSIONS.contains(holder) && holder.isProcessLive()) { ids.add(key.sessionId()); return; } @@ -1156,8 +1222,7 @@ private Set sessionsOwnedByOtherLiveTabs(java.io.File dir, return; } for (ClaudeSessionController owner : claims.owners) { - if (owner != this && !claims.borrowed.contains(owner) - && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { + if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { ids.add(key.sessionId()); return; } @@ -1185,11 +1250,11 @@ private Set claimedByOthers(java.io.File dir, java.nio.file.Path configD // A session shared with another tab (both resumed the same saved // conversation) is still this tab's own conversation: excluding it // would make the scan pick an unrelated file. - if (claims.owners.contains(this)) { + if (claims.holders.contains(this)) { return; } - for (ClaudeSessionController owner : claims.owners) { - if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { + for (ClaudeSessionController holder : claims.holders) { + if (holder != this && LIVE_SESSIONS.contains(holder) && holder.isProcessLive()) { ids.add(key.sessionId()); return; } @@ -2826,12 +2891,17 @@ private void insertTerminalChar(char c) { */ private void applyCursorKey(char key) { switch (key) { - case 'D' -> terminalInputCursor = - terminalInputLine.offsetByCodePoints( - Math.min(terminalInputCursor, terminalInputLine.length()), -1); - case 'C' -> terminalInputCursor = - terminalInputLine.offsetByCodePoints( - Math.min(terminalInputCursor, terminalInputLine.length()), 1); + case 'D' -> { + int at = Math.min(terminalInputCursor, terminalInputLine.length()); + terminalInputCursor = + at <= 0 ? 0 : terminalInputLine.offsetByCodePoints(at, -1); + } + case 'C' -> { + 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(); default -> { /* Up/Down and other keys do not edit the line */ } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 2046949f..fd37cb6d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -351,19 +351,13 @@ 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, @@ -1351,6 +1345,22 @@ 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. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 22e1272d..693b0093 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2269,6 +2269,64 @@ void borrowedClaimDoesNotStealTheOwnersConversation() throws Exception { } } + /** + * Two NEW tabs that have not resolved their session yet submit the same + * text. Neither knows which file is its own: the first scan of an + * unclaimed file can only guess, so that claim stays provisional instead of + * establishing ownership over a sibling's conversation. When the second + * file appears, the tab that guessed first must return to it while the tab + * that merely joined keeps the conversation it tracks. + */ + @Test + void twoUnresolvedNewTabsWithTheSamePromptKeepTheirOwnConversations() 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 provisional claim must not cost the tab that joined the conversation later"); + assertEquals("a", controller.resolveActiveSessionId(), + "the tab that guessed first must return to its own file"); + + assertEquals("a", controller.resolveActiveSessionId(), + "both tabs must keep their own conversations on later scans"); + assertEquals("b", tabB.resolveActiveSessionId()); + } finally { + tabB.stopProcess(); + } + } + /** * 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 @@ -2396,6 +2454,29 @@ void backspaceAndDeleteRemoveWholeSupplementaryCharacters() { "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 // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java index 1b96de62..5171d793 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -131,6 +131,29 @@ void aTabWithAPendingLaunchServesThatDirectory() throws Exception { } } + /** + * File references pasted or dropped into the prompt are stored relative to + * the project the session will run in. During a pending launch the model + * still names the previous project, so the supplier must use the pending + * one — otherwise the reference points at a path inside the wrong project. + */ + @Test + void promptFileReferencesUseThePendingLaunchDirectory() throws Exception { + Path tmp = Files.createTempDirectory("pending-launch-prompt-test"); + try { + File previous = tmp.resolve("repo").toFile(); + File pending = tmp.resolve("repo").resolve("module").toFile(); + ClaudeSessionTab tab = new ClaudeSessionTab(); + modelOf(tab).setWorkingDirectory(previous); + setField(tab, "pendingLaunchDir", pending); + + assertEquals(pending.getAbsolutePath(), tab.currentOrPendingDirectoryPath(), + "file references must be relative to the project being launched"); + } finally { + tmp.toFile().delete(); + } + } + private static String pathToRestore(ClaudeSessionTab tab) throws Exception { Field f = ClaudeSessionTab.class.getDeclaredField("pathToRestore"); f.setAccessible(true); From 4a5c9e37337402eec5797b2149a090c5eea75708 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 10:35:00 +0300 Subject: [PATCH 029/111] Fixed displaced provisional claims and history-recalled prompt submissions Two unresolved NEW tabs submitting identical text can only guess which session file is theirs, and the guess is not distinguishable afterwards: both files carry the same prompt with the same timestamps. Displacing the first claim when the sibling also holds the file swapped the two conversations in one scan order and left them correct in the other. A tab now keeps the conversation it tracks as long as that file bears its own prompt and no other live tab established it at launch; only an established (--continue/--resume) claim by another tab still forces it to leave. Provisional guesses never count as ownership in either direction, so both scan orders keep the shared conversation instead of swapping it. Enter pressed on an empty recorded line was ignored, but the TUI had filled that line from its prompt history (Up/Down): a prompt was submitted whose text the plugin never sees. The submission advanced only externalInputAt, so the text-evidence pass was skipped and the tab kept a conversation its process had already left. Up/Down on an empty line is now remembered, and the following Enter records the submission time with unknown text; the identity scan then adopts the conversation another live tab holds that this process wrote into after that moment. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 138 ++++++++++++------ .../ClaudeSessionControllerTest.java | 123 ++++++++++++++-- 2 files changed, 206 insertions(+), 55 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index bd5ff88a..ac6a45fe 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -155,10 +155,11 @@ private record SessionKey(java.nio.file.Path configDir, * established, and only ownership elsewhere makes the file foreign to * this tab. *

  • {@code provisional} (a holder in neither set) — a {@code NEW} tab - * guessed that this file is its own. Any tab may guess wrong when - * another unresolved tab writes the same prompt, so a provisional - * claim must yield when another live tab follows the same file and a - * file nobody follows holds the prompt.
  • + * guessed that this file is its own. A guess is not ownership: it must + * never make the file look foreign to the tab that established it, + * nor be displaced merely because another unresolved tab follows the + * same file (with identical prompts the evidence cannot tell the two + * apart). *
  • {@link #borrowed} — the tab joined a file another live tab already * held (an in-process resume into a sibling's conversation, or a NEW * tab following it until its own file appears).
  • @@ -300,6 +301,16 @@ private static final class SessionClaims { /** 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; + + /** Set when Enter submitted a history-recalled line the parser did not record. */ + private boolean terminalRecalledSubmission; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -843,7 +854,6 @@ boolean refreshSessionIdentity() { Set claimed = new HashSet<>(); Set otherLiveClaims = new HashSet<>(); Set otherOwned = new HashSet<>(); - boolean trackedProvisional; synchronized (processOwnershipLock) { generation = processGeneration; scanId = ++scanSequence; @@ -854,9 +864,6 @@ boolean refreshSessionIdentity() { externalAt = externalInputAt; promptText = lastPromptText; activeId = activeSessionId; - // A NEW tab's first guess at its own file is not ownership: another - // unresolved tab may hold the same file as a guess of its own. - trackedProvisional = claimIsProvisional(dir, configDir, activeId); if (dir != null && configDir != null && since > 0) { claimed.addAll(claimedByOthers(dir, configDir)); otherLiveClaims.addAll(claimedByOtherLiveTabs(dir, configDir)); @@ -900,28 +907,19 @@ boolean refreshSessionIdentity() { dir.toPath(), configDir, since, ownExclude, PROMPT_EVIDENCE_CANDIDATES)); // A session another live tab established is not this tab's own, // even when it is the identity currently tracked (a false - // adoption). A merely borrowed claim does not count: a sibling - // following this tab's conversation must not cost this tab the - // session it established. A provisional claim (this NEW tab's - // guess) loses the preference when another tab also holds the - // file: until ownership is clear, the tab must not keep the - // guessed file merely because it guessed first. + // adoption). A merely borrowed or provisionally claimed file + // does not count: a sibling following this tab's conversation + // must not cost this tab the session it established. + // + // When the tracked file bears this tab's own prompt and no + // other tab established it, the tab keeps it: with two + // unresolved NEW tabs submitting identical text, the evidence + // is indistinguishable, and displacing the current claim would + // merely swap the two conversations. String ownPreferred = activeId != null && !otherOwned.contains(activeId) - && !(trackedProvisional && otherLiveClaims.contains(activeId)) ? activeId : null; - if (ownPreferred == null) { - // Prefer the conversation no other live tab holds: with two - // initially unresolved NEW tabs, that is the only file that - // can be this tab's own. - Set unshared = new HashSet<>(own); - unshared.removeAll(otherLiveClaims); - newest = findPromptBearingSession(dir, configDir, unshared, null, - promptAt, promptText); - } - if (newest == null) { - newest = findPromptBearingSession(dir, configDir, own, ownPreferred, - promptAt, promptText); - } + newest = findPromptBearingSession(dir, configDir, own, ownPreferred, + promptAt, promptText); if (newest == null) { // Only then may a conversation another live tab writes be // adopted: that is an in-process /resume from this terminal. @@ -936,6 +934,24 @@ boolean refreshSessionIdentity() { newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since, exclude); } + if (newest == null && promptAt > 0 && promptAt >= externalAt + && (promptText == null || promptText.isBlank())) { + // A prompt recalled from the TUI history: its text is unknown, so + // no file can match it as evidence. The conversation another live + // tab holds that this process wrote into after the submission is + // still the one on screen (an in-process resume into a sibling's + // conversation), so it must be adopted despite the exclusion. + Set shared = new HashSet<>(claimed); + shared.addAll(otherLiveClaims); + if (activeId != null) shared.add(activeId); + newest = newestSharedSessionWrittenSince(dir, configDir, since, + shared, exclude); + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse timestamps can round the write below the submission. + newest = newestSharedSessionWrittenSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, shared, exclude); + } + } if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { // Coarse filesystem timestamps can round a file written right // after the prompt below its cutoff. Retry with a slack so a @@ -988,6 +1004,29 @@ && containsPrompt(dir, configDir, sessionId, promptAt, promptText)) { return null; } + /** + * Returns the newest session ID in {@code shared} whose file was written at + * or after {@code cutoff}, or {@code null}. + * + *

    Used when the submitted prompt's text is unknown (a history recall): + * only a conversation another live tab holds can be the one this process + * switched into, so unrelated newer files must not be adopted. + */ + private static String newestSharedSessionWrittenSince(java.io.File dir, + java.nio.file.Path configDir, + long cutoff, + Set shared, + Set exclude) { + if (shared.isEmpty()) return null; + Set sharedExclude = new HashSet<>(exclude); + sharedExclude.removeAll(shared); + for (String id : ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, cutoff, sharedExclude, PROMPT_EVIDENCE_CANDIDATES)) { + if (shared.contains(id)) return id; + } + return null; + } + /** Returns whether {@code sessionId} holds {@code promptText} since {@code promptAt}. */ private static boolean containsPrompt(java.io.File dir, java.nio.file.Path configDir, String sessionId, long promptAt, String promptText) { @@ -1135,20 +1174,6 @@ private boolean heldByAnotherLiveTab(SessionClaims claims) { return false; } - /** - * {@code true} if this controller holds {@code sessionId} only as a guess - * for a {@code NEW} session: it neither established the file at launch nor - * joined a tab that already held it. - */ - private boolean claimIsProvisional(java.io.File dir, java.nio.file.Path configDir, - String sessionId) { - SessionKey key = keyFor(dir, configDir, sessionId); - if (key == null) return false; - SessionClaims claims = SESSION_CLAIMS.get(key); - return claims != null && claims.holders.contains(this) - && !claims.owners.contains(this) && !claims.borrowed.contains(this); - } - private void releaseClaim(java.io.File dir, java.nio.file.Path configDir, String sessionId) { SessionKey key = keyFor(dir, configDir, sessionId); @@ -2787,11 +2812,14 @@ private void writePty(String text) throws IOException { private String onTerminalInput(byte[] bytes, long now) { String submitted; boolean entered; + boolean recalled; synchronized (terminalInputLine) { terminalLineEntered = false; submitted = appendTerminalInput(bytes); entered = terminalLineEntered; terminalLineEntered = false; + recalled = terminalRecalledSubmission; + terminalRecalledSubmission = false; } if (entered) { externalInputAt = now; @@ -2800,6 +2828,14 @@ private String onTerminalInput(byte[] bytes, long now) { 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; } @@ -2854,10 +2890,18 @@ private String appendTerminalInput(byte[] bytes) { } } else if (c == '\r' || c == '\n') { String line = terminalInputLine.toString().trim(); + boolean recalled = terminalHistoryRecall; + terminalHistoryRecall = false; terminalInputLine.setLength(0); terminalInputCursor = 0; terminalLineEntered = true; - if (!line.isEmpty()) submitted = line; + if (!line.isEmpty()) { + submitted = line; + } else if (recalled) { + // Enter on a line filled from the TUI history: a prompt was + // submitted even though no characters were typed here. + terminalRecalledSubmission = true; + } } else if (c == 0x7f || c == 0x08) { deleteTerminalCharBeforeCursor(); } else if (c == 0x03 || c == 0x15) { // Ctrl+C, Ctrl+U @@ -2904,7 +2948,13 @@ private void applyCursorKey(char key) { } case 'H' -> terminalInputCursor = 0; case 'F' -> terminalInputCursor = terminalInputLine.length(); - default -> { /* Up/Down and other keys do not edit the line */ } + 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. + if (terminalInputLine.isEmpty()) terminalHistoryRecall = true; + } + default -> { /* other keys do not edit the line */ } } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 693b0093..9e2f2642 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2115,6 +2115,53 @@ void promptTypedInTheTerminalFollowsASharedResume() throws Exception { } } + /** + * The same resume driven by a prompt recalled from the TUI history: Up + * fills the TUI input with text the plugin never sees, and Enter submits + * it. The submission is real, so the file this process writes after it — + * the conversation on screen — must be followed even though its text + * cannot be matched. + */ + @Test + void promptRecalledFromTheTerminalHistoryFollowsTheConversationOnScreen() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + // The recalled prompt is appended to the resumed conversation. + appendUserPrompt(dir, "shared", "recalled prompt", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // Up recalls it into the TUI input; nothing is typed here. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L); + + assertEquals("shared", controller.resolveActiveSessionId(), + "a recalled prompt proves the conversation on screen"); + } finally { + tabB.stopProcess(); + } + } + /** * After joining another tab's conversation and submitting a prompt there, * the identity scan may only run after the user started typing the next @@ -2271,14 +2318,15 @@ void borrowedClaimDoesNotStealTheOwnersConversation() throws Exception { /** * Two NEW tabs that have not resolved their session yet submit the same - * text. Neither knows which file is its own: the first scan of an - * unclaimed file can only guess, so that claim stays provisional instead of - * establishing ownership over a sibling's conversation. When the second - * file appears, the tab that guessed first must return to it while the tab - * that merely joined keeps the conversation it tracks. + * 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 twoUnresolvedNewTabsWithTheSamePromptKeepTheirOwnConversations() throws Exception { + 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); @@ -2315,18 +2363,71 @@ void twoUnresolvedNewTabsWithTheSamePromptKeepTheirOwnConversations() throws Exc setMtime(dir.resolve("a.jsonl"), START_MS + 11_200L); assertEquals("b", tabB.resolveActiveSessionId(), - "a provisional claim must not cost the tab that joined the conversation later"); - assertEquals("a", controller.resolveActiveSessionId(), - "the tab that guessed first must return to its own file"); + "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("a", controller.resolveActiveSessionId(), - "both tabs must keep their own conversations on later scans"); + 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 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(); + } + } + /** * 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 From 32c6c4aad2862d8999980433c86a712013fe67e4 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 10:56:49 +0300 Subject: [PATCH 030/111] Fixed standalone Esc, pending config overrides and post-exit identity scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standalone Esc arrives as its own write (the TUI uses it to dismiss the autocomplete). The parser kept the escape state and consumed the next character unconditionally, so "hello", Esc, Enter recorded no submission at all and Esc, "hello", Enter recorded "ello" — the prompt evidence then could not match the resumed conversation. An Esc carried over from the previous write is now standalone: the following character is processed as ordinary input again. An ESC+char inside one write is still a two-character escape (e.g. Alt+x), as terminals send it. The tab resolved a pending launch's config dir with ClaudeProfileStore.resolveStorageDir(), while the process resolves it with ClaudeProcess.resolveConfigDir(), which also applies CLAUDE_CONFIG_DIR from the profile's extra environment variables. Opening Save & Switch during launch preparation could therefore list one store and hand the resume ID to a process using another. The tab now resolves the directory exactly as the process does. When Claude quit or crashed while its tab stayed open, the status timer kept queueing identity scans that enumerated the session store forever — every 500 ms for an unresolved session — on the single shared worker, delaying identity updates for live tabs. Scheduling is now gated on the PTY still running. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 45 ++++++++++++++++--- .../claudecodegui/ui/ClaudeSessionTab.java | 26 ++++++++--- .../ClaudeSessionControllerTest.java | 38 ++++++++++++++++ .../ui/ClaudeSessionTabPendingLaunchTest.java | 32 +++++++++++++ 4 files changed, 131 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index ac6a45fe..c5a39be5 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1112,9 +1112,18 @@ void refreshSessionIdentityIfDue() { * Queues one periodic identity refresh on the shared worker, so that the * screen poll never enumerates the session store on the EDT. At most one * scan per controller is in flight at a time. + * + *

    Nothing is queued once the PTY has exited: the tab can stay open after + * Claude quits, and a dead process has no conversation to follow. Without + * this gate each such tab would keep enumerating the session store twice a + * second (unresolved session) or every five seconds on the single shared + * worker, delaying live tabs. + * + * @return {@code false} when no PTY is running and nothing was queued */ - private void scheduleIdentityRefresh() { - if (!identityScanPending.compareAndSet(false, true)) return; + boolean scheduleIdentityRefresh() { + if (!isProcessLive()) return false; + if (!identityScanPending.compareAndSet(false, true)) return true; try { IDENTITY_SCANNER.execute(() -> { try { @@ -1128,6 +1137,12 @@ private void scheduleIdentityRefresh() { } catch (java.util.concurrent.RejectedExecutionException e) { identityScanPending.set(false); } + return true; + } + + /** Package-private for tests: the PTY is no longer running (Claude exited). */ + void simulateProcessNotRunning() { + claudeProcess = new ClaudeProcess(); } private static SessionKey keyFor(java.io.File dir, java.nio.file.Path configDir, @@ -2853,6 +2868,12 @@ private String onTerminalInput(byte[] bytes, long now) { 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 (c == 0x1b) { @@ -2860,9 +2881,23 @@ private String appendTerminalInput(byte[] bytes) { terminalEscapeParams.setLength(0); terminalPasteAfterCR = 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). - terminalInputEscape = c == '[' ? 2 : c == 'O' ? 3 : 0; + // 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 the character that followed belongs to the input, so it + // is processed again as ordinary input. + if (c == '[') { + terminalInputEscape = 2; + } else if (c == 'O') { + terminalInputEscape = 3; + } else { + terminalInputEscape = 0; + if (carriedEscape) { + i--; + } + } + carriedEscape = false; } else if (terminalInputEscape == 2) { // CSI: parameter and intermediate bytes (0x20–0x3f) precede // the final byte (0x40–0x7e), which ends the sequence. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index fd37cb6d..e8c663a1 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -12,6 +12,7 @@ import io.github.nbplugins.claudecodegui.model.SavedSession; import io.github.nbplugins.claudecodegui.model.SessionLifecycle; import io.github.nbplugins.claudecodegui.model.SessionMode; +import io.github.nbplugins.claudecodegui.process.ClaudeProcess; import io.github.nbplugins.claudecodegui.process.ClaudeSessionStore; import io.github.nbplugins.claudecodegui.settings.ClaudeCodePreferences; import io.github.nbplugins.claudecodegui.settings.ClaudeProfile; @@ -847,11 +848,8 @@ private void startSession(File dir, String profileName, String extraCliArgs, 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()); - } + configDir = resolveLaunchConfigDir(ClaudeProfileStore.findByName(profileName), + ClaudeCodePreferences.getProfilesDir()); } final Path capturedConfigDir = configDir; // Until the process starts, the model still describes the previous @@ -1384,6 +1382,24 @@ private Path currentOrPendingConfigDir() { 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, or {@code null} for the default store + */ + static Path resolveLaunchConfigDir(ClaudeProfile profile, Path profilesDir) { + if (profile == null || profile.isDefault()) return null; + return ClaudeProcess.resolveConfigDir(profile, profilesDir); + } + public void onSaveAndSwitch(String sessionName, SessionMode mode, String resumeId) { File dir = currentOrPendingDirectory(); String profileName = selectorPanel.getSelectedProfileName(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 9e2f2642..5464b4a4 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2428,6 +2428,44 @@ void aSiblingBorrowingAProvisionalClaimDoesNotDisplaceIt() throws Exception { } } + /** + * 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 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java index 5171d793..71b9984a 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -154,6 +154,38 @@ void promptFileReferencesUseThePendingLaunchDirectory() throws Exception { } } + /** + * A profile may set {@code CLAUDE_CONFIG_DIR} through its extra environment + * variables, which takes precedence over its storage dir. A pending launch + * must resolve the directory exactly as the process will, or the dialog + * would list one store and the started process use another. + */ + @Test + void pendingLaunchConfigDirAppliesTheProfilesEnvironmentOverride() throws Exception { + Path profiles = Files.createTempDirectory("pending-launch-config-test"); + try { + io.github.nbplugins.claudecodegui.settings.ClaudeProfile profile = + io.github.nbplugins.claudecodegui.settings.ClaudeProfile.createNamed("Work"); + assertEquals(profiles.resolve("Work"), + ClaudeSessionTab.resolveLaunchConfigDir(profile, profiles), + "a named profile uses its storage dir by default"); + + Path override = profiles.resolve("override-store"); + profile.setExtraEnvVars(java.util.Collections.singletonList( + new String[]{"CLAUDE_CONFIG_DIR", override.toString()})); + assertEquals(override, + ClaudeSessionTab.resolveLaunchConfigDir(profile, profiles), + "the environment override must win over the storage dir"); + + assertNull(ClaudeSessionTab.resolveLaunchConfigDir( + io.github.nbplugins.claudecodegui.settings.ClaudeProfile.createDefault(), + profiles), + "the default profile keeps the default store"); + } finally { + profiles.toFile().delete(); + } + } + private static String pathToRestore(ClaudeSessionTab tab) throws Exception { Field f = ClaudeSessionTab.class.getDeclaredField("pathToRestore"); f.setAccessible(true); From 6e63df3558448bc3396b8ff58d2dcad9a86b6077 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 11:20:57 +0300 Subject: [PATCH 031/111] Fixed menu navigation recall and quadratic record reassembly While a choice menu is open, Up/Down move the menu cursor and Enter selects the item, yet the recorded line stays empty exactly as it does after a prompt recalled from the TUI history. The submission was therefore recorded as a recall with unknown text, which enables the mtime-only fallback that adopts a conversation held by another live tab: with the sibling's file merely touched, the tab switched its identity to that file and Save & Switch renamed the wrong session. Arrow keys now count as a history recall only when no choice menu is open. containsUserPromptSince scans a session file backwards in 512 KiB blocks to find prompt evidence. Reassembling a record that spans many blocks allocated a new array and copied the whole accumulated prefix on every iteration, and the newline search rescanned it; the cost was quadratic in the record size, so a large attachment or tool record held the shared identity worker and the synchronous Save & Switch lookup for seconds. The blocks of an unfinished record are now kept as separate chunks and assembled once, when the record's first newline is reached. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 9 +++- .../process/ClaudeSessionStore.java | 48 +++++++++++------- .../ClaudeSessionControllerTest.java | 50 +++++++++++++++++++ .../process/ClaudeSessionStoreTest.java | 45 +++++++++++++++++ 4 files changed, 132 insertions(+), 20 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index c5a39be5..d4bb70b5 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -2986,8 +2986,13 @@ private void applyCursorKey(char key) { 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. - if (terminalInputLine.isEmpty()) terminalHistoryRecall = true; + // 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. + if (terminalInputLine.isEmpty() && model.getActiveChoiceMenu() == null) { + terminalHistoryRecall = true; + } } default -> { /* other keys do not edit the line */ } } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 6e15f5bb..17a4a8d6 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -283,30 +283,42 @@ public static boolean containsUserPromptSince(Path workingDir, Path claudeConfig // than the cutoff is reached, since records are appended in time // order. long pos = raf.length(); - byte[] carry = new byte[0]; // tail of the line cut at the block start + // Bytes read so far that belong to a record cut by the block + // boundaries, kept as separate chunks in file order: a record + // spanning many blocks is never recopied while it is being found. + java.util.ArrayDeque pending = new java.util.ArrayDeque<>(); + byte[] buffer = new byte[PROMPT_EVIDENCE_BLOCK_BYTES]; while (pos > 0) { int size = (int) Math.min(PROMPT_EVIDENCE_BLOCK_BYTES, pos); long start = pos - size; - byte[] block = new byte[size + carry.length]; raf.seek(start); - raf.readFully(block, 0, size); - System.arraycopy(carry, 0, block, size, carry.length); + raf.readFully(buffer, 0, size); pos = start; - int firstLine = 0; - if (pos > 0) { + int newline = indexOf(buffer, size, (byte) '\n'); + if (pos > 0 && newline < 0) { + // The record fills this whole block and continues earlier: + // keep the bytes and look for its start in the next block. + pending.addFirst(java.util.Arrays.copyOf(buffer, size)); + continue; + } + + // Assemble the complete lines once: the part of this block after + // its first newline (or all of it when the file starts here), + // followed by the chunks already collected. + int firstLine = pos > 0 ? newline + 1 : 0; + java.io.ByteArrayOutputStream joined = new java.io.ByteArrayOutputStream( + size + pending.stream().mapToInt(chunk -> chunk.length).sum()); + joined.write(buffer, firstLine, size - firstLine); + for (byte[] chunk : pending) joined.write(chunk, 0, chunk.length); + pending.clear(); + if (pos > 0 && newline > 0) { // The first line is cut in the middle: its beginning is in // the block read next, so keep its bytes for that block. - int newline = indexOf(block, (byte) '\n'); - if (newline < 0) { - carry = block; - continue; - } - carry = java.util.Arrays.copyOf(block, newline); - firstLine = newline + 1; + pending.addFirst(java.util.Arrays.copyOf(buffer, newline)); } - String text = new String(block, firstLine, block.length - firstLine, - StandardCharsets.UTF_8); + + String text = joined.toString(StandardCharsets.UTF_8); String[] lines = text.split("\n", -1); for (int i = lines.length - 1; i >= 0; i--) { if (isUserMessageWith(lines[i], sinceMillis, wanted)) return true; @@ -320,9 +332,9 @@ public static boolean containsUserPromptSince(Path workingDir, Path claudeConfig } } - /** Returns the index of the first occurrence of {@code b} in {@code bytes}, or {@code -1}. */ - private static int indexOf(byte[] bytes, byte b) { - for (int i = 0; i < bytes.length; i++) { + /** Returns the index of the first occurrence of {@code b} in the first {@code length} bytes, or {@code -1}. */ + private static int indexOf(byte[] bytes, int length, byte b) { + for (int i = 0; i < length; i++) { if (bytes[i] == b) return i; } return -1; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 5464b4a4..c333295d 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2162,6 +2162,56 @@ void promptRecalledFromTheTerminalHistoryFollowsTheConversationOnScreen() throws } } + /** + * 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 9f6d1175..d9fb29e6 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -411,6 +411,51 @@ void containsUserPromptSince_findsPromptPushedFarFromTheEnd() throws IOException OLD_TS.plusSeconds(30).toEpochMilli(), "the prompt from this tab")); } + /** + * A record longer than the read block spans several blocks; the backward + * scan must still reassemble the record boundaries correctly and find the + * prompt that follows it. + */ + @Test + void containsUserPromptSince_findsPromptAfterRecordsSpanningSeveralBlocks() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + + StringBuilder sb = new StringBuilder(); + sb.append(userLine("prompt after huge records", OLD_TS.plusSeconds(60))).append('\n'); + // Each record is about 1 MiB, i.e. two read blocks of 512 KiB. + String filler = "y".repeat(1024 * 1024); + for (int i = 0; i < 3; i++) { + sb.append("{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"") + .append(filler).append("\"},\"timestamp\":\"") + .append(OLD_TS.plusSeconds(61 + i)).append("\"}\n"); + } + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.plusSeconds(30).toEpochMilli(), "prompt after huge records")); + } + + /** + * A file that is one record with no newline at all must be scanned to its + * very beginning without losing the first bytes kept across blocks. + */ + @Test + void containsUserPromptSince_scansAFileMadeOfOneHugeRecord() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + + StringBuilder sb = new StringBuilder(); + sb.append("{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"") + .append("z".repeat(3 * 1024 * 1024)).append("\"}"); + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "never present")); + } + @Test void containsUserPromptSince_noEvidence_returnsFalse() throws IOException { Path workDir = tmpDir.resolve("myproject"); From 9844be21f2664a89936b63030498624b3c56ae88 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 15:31:14 +0300 Subject: [PATCH 032/111] Fixed a lost title on stop, pasted tabs and edited history recalls Save & Switch resolved the running session before stopping the PTY. A NEW session writes its first record while its turn finishes, so the file could appear between that lookup and the process exit; the identity inputs were then cleared with the title lost. The launch snapshot is now captured before stopping and an unresolved session gets a final lookup once the writer has exited. A bracketed paste is forwarded to the PTY byte for byte, but the recorded line dropped tab characters (only bytes >= 0x20 were kept). Claude matches the prompt exactly, so a pasted prompt containing a tab could not be found in the session file. Tabs are now recorded inside a paste, while a tab typed live still completes input rather than entering text. Up recalls a prompt from the TUI history with text this parser never sees. If the user edited it before Enter, the recorded characters were published as the complete prompt, so the exact-text evidence could not match the message actually written. A submission from a recalled line is now always recorded with unknown text, unless the line was cleared (Ctrl+U / Ctrl+C), after which what is typed is known in full again. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 34 +++++- .../ClaudeSessionControllerTest.java | 113 ++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index d4bb70b5..355a85b8 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -780,9 +780,24 @@ public void stopAndRename(String sessionName) { // "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 = rename ? resolveActiveSessionId() : null; + // The identity inputs are cleared by stopProcess(); keep the snapshot + // for one last lookup once the process has exited. + Set preStart; + long since; + synchronized (processOwnershipLock) { + preStart = preStartSessionIds; + since = Math.max(processStartedAt, promptSubmittedAt); + } stopProcess(); if (!rename) return; + if (sessionId == null && dir != null && configDir != null && since > 0) { + // A new session's first record can be written between the lookup + // above and the moment the process exits — Claude flushes its turn + // while shutting down. The writer is gone now, so the file is final. + sessionId = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, since, preStart); + } if (dir == null || configDir == null || sessionId == null) { LOG.warning("Could not rename session: no active session file found in " + ClaudeSessionStore.sessionDirForLog( @@ -2919,7 +2934,9 @@ private String appendTerminalInput(byte[] bytes) { } else if (c == '\n') { if (!terminalPasteAfterCR) insertTerminalChar('\n'); terminalPasteAfterCR = false; - } else if (c >= 0x20) { + } else if (c >= 0x20 || c == '\t') { + // Tab is literal text in a paste (Claude matches the prompt + // exactly), while the same key typed live completes it. insertTerminalChar(c); terminalPasteAfterCR = false; } @@ -2930,18 +2947,23 @@ private String appendTerminalInput(byte[] bytes) { terminalInputLine.setLength(0); terminalInputCursor = 0; terminalLineEntered = true; - if (!line.isEmpty()) { - submitted = line; - } else if (recalled) { - // Enter on a line filled from the TUI history: a prompt was - // submitted even though no characters were typed here. + if (recalled) { + // The line was filled from the TUI history (Up/Down) 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. terminalRecalledSubmission = true; + } else if (!line.isEmpty()) { + 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 prompt, so what + // is typed next is known in full again. + terminalHistoryRecall = false; } else if (c >= 0x20) { insertTerminalChar(c); } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index c333295d..41ba9718 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2162,6 +2162,119 @@ void promptRecalledFromTheTerminalHistoryFollowsTheConversationOnScreen() throws } } + /** + * A NEW session's first record can be written between the pre-stop + * identity lookup and the moment the process exits. The rename must still + * target that file instead of silently dropping the entered name. + */ + @Test + void aSessionFileWrittenWhileStoppingIsStillRenamed() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + java.util.concurrent.atomic.AtomicBoolean written = + new java.util.concurrent.atomic.AtomicBoolean(); + ClaudeSessionController underTest = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + public void stopProcess() { + if (written.compareAndSet(false, true)) { + try { + writeSession(dir, "late", "late"); + appendUserPrompt(dir, "late", "first prompt", + START_MS + 5_000L); + setMtime(dir.resolve("late.jsonl"), START_MS + 5_000L); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + super.stopProcess(); + } + }; + underTest.simulateProcessStart(START_MS, true); + underTest.simulateLiveSession(config, null, Set.of()); + + underTest.stopAndRename("Late session"); + + io.github.nbplugins.claudecodegui.model.SavedSession saved = + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore.findSession( + work, config, "late"); + assertNotNull(saved, "the session written while stopping must be renamed"); + assertEquals("Late session", saved.customTitle()); + } + + /** + * Up recalls a prompt from the TUI history and the user edits it before + * submitting. Only the edited characters reach this parser, so the full + * prompt is unknown; treating the fragment as the whole prompt would make + * the exact-text evidence fail and Save & Switch rename the wrong session. + */ + @Test + void anEditedHistoryRecallKeepsThePromptTextUnknown() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + appendUserPrompt(dir, "shared", "recalled prompt suffix", + START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // Up recalls the prompt, then " suffix" is appended and submitted. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + controller.simulateTerminalInput( + " suffix".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_001L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_002L), + "the recorded characters are only part of the recalled prompt"); + + assertEquals("shared", controller.resolveActiveSessionId(), + "an edited recall proves the conversation on screen by time"); + } finally { + tabB.stopProcess(); + } + } + + /** Clearing the input with Ctrl+U discards a recalled prompt: the next text is known. */ + @Test + void clearingARecalledPromptMakesTheNextSubmissionKnown() { + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS); + controller.simulateTerminalInput(new byte[]{0x15}, START_MS + 1L); + + assertEquals("typed after clearing", controller.simulateTerminalInput( + "typed after clearing\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L)); + } + + /** A tab inside a bracketed paste is literal prompt text. */ + @Test + void aTabInsideABracketedPasteIsRecordedAsTyped() { + String paste = "\u001b[200~first\tsecond\u001b[201~\r"; + + assertEquals("first\tsecond", controller.simulateTerminalInput( + paste.getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS)); + } + /** * 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 From 865c3230adf79716e525aaf33859c66f2d382e97 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 15:49:56 +0300 Subject: [PATCH 033/111] Fixed renaming the previous conversation when switching in the TUI at stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save & Switch resolved the running session once, before stopping the PTY, and used that ID for the rename. When the user switched conversations inside the TUI, the new session's first record can still be pending at that moment: the writer produced it while shutting down, but the rename had already been decided for the previous conversation, so the entered title landed on the wrong session. The PTY shutdown is now split from the state reset: the writer is stopped first and the identity is resolved again with the same evidence rules (claims of other live tabs, prompt text, file times), so the conversation that was actually on screen — including a just-written switched one — receives the title. The writer-only stop is package-private so tests can place the last write exactly in that window. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 41 ++++++++------ .../ClaudeSessionControllerTest.java | 53 ++++++++++++++++++- 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 355a85b8..41bc02fd 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -703,7 +703,12 @@ public String getOpenAIProxyUuid() { */ public boolean canSendPrompt() { return connector != null; } - public void stopProcess() { + /** + * 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; @@ -714,9 +719,17 @@ public void stopProcess() { } ClaudeProcess processToStop = claudeProcess; if (processToStop != null) { - processToStop.stop(); + processToStop.killOnly(); } connector = null; + } + + public void stopProcess() { + 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 @@ -779,25 +792,19 @@ public void stopAndRename(String sessionName) { // 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 = rename ? resolveActiveSessionId() : null; - // The identity inputs are cleared by stopProcess(); keep the snapshot - // for one last lookup once the process has exited. - Set preStart; - long since; - synchronized (processOwnershipLock) { - preStart = preStartSessionIds; - since = Math.max(processStartedAt, promptSubmittedAt); + String sessionId = null; + if (rename) { + 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. + stopWriter(); + sessionId = resolveActiveSessionId(); } stopProcess(); if (!rename) return; - if (sessionId == null && dir != null && configDir != null && since > 0) { - // A new session's first record can be written between the lookup - // above and the moment the process exits — Claude flushes its turn - // while shutting down. The writer is gone now, so the file is final. - sessionId = ClaudeSessionStore.findLatestSessionIdWrittenSince( - dir.toPath(), configDir, since, preStart); - } if (dir == null || configDir == null || sessionId == null) { LOG.warning("Could not rename session: no active session file found in " + ClaudeSessionStore.sessionDirForLog( diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 41ba9718..59956a03 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2179,7 +2179,7 @@ void aSessionFileWrittenWhileStoppingIsStillRenamed() throws Exception { ClaudeSessionController underTest = new ClaudeSessionController(model, Collections::emptyList) { @Override - public void stopProcess() { + void stopWriter() { if (written.compareAndSet(false, true)) { try { writeSession(dir, "late", "late"); @@ -2190,7 +2190,7 @@ public void stopProcess() { throw new IllegalStateException(e); } } - super.stopProcess(); + super.stopWriter(); } }; underTest.simulateProcessStart(START_MS, true); @@ -2205,6 +2205,55 @@ public void stopProcess() { assertEquals("Late session", saved.customTitle()); } + /** + * A tab tracks conversation A, the user switches to B in the TUI, and B's + * first record is only written while the process shuts down. The title must + * follow B — the conversation that was on screen — not the previously + * tracked A. + */ + @Test + void aConversationSwitchedWhileStoppingIsRenamedInsteadOfThePreviousOne() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + java.util.concurrent.atomic.AtomicBoolean written = + new java.util.concurrent.atomic.AtomicBoolean(); + ClaudeSessionController underTest = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void stopWriter() { + if (written.compareAndSet(false, true)) { + try { + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "switched prompt", + START_MS + 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 5_000L); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + super.stopWriter(); + } + }; + underTest.simulateProcessStart(START_MS, true); + underTest.simulateLiveSession(config, "a", Set.of("a")); + + underTest.stopAndRename("Switched session"); + + io.github.nbplugins.claudecodegui.model.SavedSession saved = + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore.findSession( + work, config, "b"); + assertNotNull(saved, "the conversation written while stopping is the one on screen"); + assertEquals("Switched session", saved.customTitle()); + assertEquals(0, countCustomTitles(dir.resolve("a.jsonl"), "Switched session"), + "the previously tracked conversation must not be renamed"); + } + /** * Up recalls a prompt from the TUI history and the user edits it before * submitting. Only the edited characters reach this parser, so the full From 719d5d2b77b52ce96d237e6b1f02d63b4dad07a3 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 16:08:21 +0300 Subject: [PATCH 034/111] Fixed explicit resume ownership and stale history-recall state across restarts A tab's hold on a session file was classified before its launch identity was considered: any tab joining a file another live tab held became a borrower. A NEW tab that had only guessed the file held it provisionally, so an explicit --continue/--resume of the same conversation landed in the borrowed set. The guessing tab then saw no owner elsewhere, kept preferring that file even after its own prompt-bearing conversation appeared, and Save & Switch renamed the resuming tab's session. A known-at-launch identity now establishes ownership when the competing holders are provisional; only a real owner elsewhere makes the claim borrowed. The per-terminal input state was reset on stop except for the history-recall flags. Up/Down followed by a restart without submitting left the flag set, so the first fully typed prompt of the replacement process was recorded as an unknown-text recall and an unrelated newer conversation could be adopted. Both recall flags are now cleared with the rest of the terminal state. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 24 ++++++- .../ClaudeSessionControllerTest.java | 64 +++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 41bc02fd..61fdbfa8 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -754,6 +754,10 @@ public void stopProcess() { terminalInputPaste = false; terminalPasteAfterCR = false; terminalLineEntered = false; + // A draft recalled into the finished run's input says nothing + // about what the next process will submit. + terminalHistoryRecall = false; + terminalRecalledSubmission = false; } identityPollTicks = 0; LIVE_SESSIONS.remove(this); @@ -1186,12 +1190,16 @@ private void claimSession(java.io.File dir, java.nio.file.Path configDir, if (!updated.holders.contains(this)) { // Classify only a new hold: re-claiming the identity on every // scan must not turn an owner into a borrower of its own file. - if (heldByAnotherLiveTab(updated)) { + if (launchIdentityKnown && !heldAsOwnerByAnotherLiveTab(updated)) { + // An explicit --continue/--resume knows its conversation. + // A NEW tab's provisional guess — or a borrower following + // one — does not establish the file, so it must not demote + // this claim to borrowing. + updated.owners.add(this); + } else if (heldByAnotherLiveTab(updated)) { // Another tab is already writing this conversation; this // tab is only following it. updated.borrowed.add(this); - } else if (launchIdentityKnown) { - updated.owners.add(this); } // Otherwise the claim stays provisional: this NEW tab guessed // that the file is its own. @@ -1201,6 +1209,16 @@ private void claimSession(java.io.File dir, java.nio.file.Path configDir, }); } + /** {@code true} if another live tab has established (owns) {@code claims}. */ + private boolean heldAsOwnerByAnotherLiveTab(SessionClaims claims) { + for (ClaudeSessionController owner : claims.owners) { + if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { + return true; + } + } + return false; + } + /** {@code true} if another live tab already holds {@code claims}. */ private boolean heldByAnotherLiveTab(SessionClaims claims) { for (ClaudeSessionController holder : claims.holders) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 59956a03..a553e9c9 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2640,6 +2640,70 @@ void aSiblingBorrowingAProvisionalClaimDoesNotDisplaceIt() throws Exception { } } + /** + * 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(); + } + } + + /** + * 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 From d3f3d57e24efe01320bff22e1c2cbd662b013061 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 16:32:36 +0300 Subject: [PATCH 035/111] Fixed adopting a sibling turn after a history recall and picker text in prompts After a prompt recalled from the TUI history the submitted text is unknown, so the identity scan fell back to the newest file another live tab held. That is the case when the process really switched conversations in-process, but it is also exactly what a sibling tab looks like while it finishes its own turn: the tab adopted the sibling's session and Save & Switch titled it with this tab's name. The fallback now additionally requires a user message written at or after the submission in that file; a sibling's turn appends only assistant output, so a newer file time alone is no longer treated as evidence. Text typed into the /resume picker's search box stayed in the recorded input line, because the GUI's selection writes to the PTY through the plugin path that the input listener ignores. The next prompt submitted from the terminal was then recorded as "", the exact-text evidence could not match it, and the mtime fallbacks excluded the destination as another tab's claim. Choosing an option now resets the recorded input line, as does a run stop (shared helper). Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 73 +++++++++++++------ .../process/ClaudeSessionStore.java | 51 ++++++++++++- .../ClaudeSessionControllerTest.java | 66 +++++++++++++++++ 3 files changed, 162 insertions(+), 28 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 61fdbfa8..69b30f04 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -746,19 +746,7 @@ public void stopProcess() { promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; - synchronized (terminalInputLine) { - terminalInputLine.setLength(0); - terminalInputCursor = 0; - terminalInputEscape = 0; - terminalEscapeParams.setLength(0); - terminalInputPaste = false; - terminalPasteAfterCR = false; - terminalLineEntered = false; - // A draft recalled into the finished run's input says nothing - // about what the next process will submit. - terminalHistoryRecall = false; - terminalRecalledSubmission = false; - } + resetTerminalInputRecording(); identityPollTicks = 0; LIVE_SESSIONS.remove(this); releaseAllClaims(); @@ -964,18 +952,19 @@ boolean refreshSessionIdentity() { && (promptText == null || promptText.isBlank())) { // A prompt recalled from the TUI history: its text is unknown, so // no file can match it as evidence. The conversation another live - // tab holds that this process wrote into after the submission is - // still the one on screen (an in-process resume into a sibling's - // conversation), so it must be adopted despite the exclusion. + // tab holds that this process switched into must then show a user + // record written at or after the submission — a merely newer file + // time is that tab's own turn, not evidence of a switch. Set shared = new HashSet<>(claimed); shared.addAll(otherLiveClaims); if (activeId != null) shared.add(activeId); newest = newestSharedSessionWrittenSince(dir, configDir, since, - shared, exclude); + promptAt, shared, exclude); if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { - // Coarse timestamps can round the write below the submission. + // Coarse file times can round the write below the submission; + // the record's own timestamp still has to match. newest = newestSharedSessionWrittenSince(dir, configDir, - since - SESSION_MTIME_GRANULARITY_MS, shared, exclude); + since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); } } if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { @@ -1032,23 +1021,30 @@ && containsPrompt(dir, configDir, sessionId, promptAt, promptText)) { /** * Returns the newest session ID in {@code shared} whose file was written at - * or after {@code cutoff}, or {@code null}. + * or after {@code mtimeCutoff} and holds a user message at or after + * {@code promptCutoff}, or {@code null}. * *

    Used when the submitted prompt's text is unknown (a history recall): * only a conversation another live tab holds can be the one this process - * switched into, so unrelated newer files must not be adopted. + * 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 cutoff, + long mtimeCutoff, + long promptCutoff, Set shared, Set exclude) { if (shared.isEmpty()) return null; Set sharedExclude = new HashSet<>(exclude); sharedExclude.removeAll(shared); for (String id : ClaudeSessionStore.listSessionIdsWrittenSince( - dir.toPath(), configDir, cutoff, sharedExclude, PROMPT_EVIDENCE_CANDIDATES)) { - if (shared.contains(id)) return id; + dir.toPath(), configDir, mtimeCutoff, sharedExclude, PROMPT_EVIDENCE_CANDIDATES)) { + if (shared.contains(id) && ClaudeSessionStore.containsUserMessageSince( + dir.toPath(), configDir, id, promptCutoff)) { + return id; + } } return null; } @@ -1166,6 +1162,26 @@ boolean scheduleIdentityRefresh() { 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() { + synchronized (terminalInputLine) { + terminalInputLine.setLength(0); + terminalInputCursor = 0; + terminalInputEscape = 0; + terminalEscapeParams.setLength(0); + terminalInputPaste = false; + terminalPasteAfterCR = false; + terminalLineEntered = false; + terminalHistoryRecall = false; + terminalRecalledSubmission = false; + } + } + /** Package-private for tests: the PTY is no longer running (Claude exited). */ void simulateProcessNotRunning() { claudeProcess = new ClaudeProcess(); @@ -1487,6 +1503,9 @@ public void triggerPromptScan() { * @param answer the answer string from the choice-menu callback */ public void writePtyAnswer(String 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. + resetTerminalInputRecording(); try { if (answer == null) { LOG.fine("[PTY write] \\x1b (Cancel/ESC)"); @@ -2835,6 +2854,12 @@ private void sendCancelToPty(byte[] bytes) throws IOException { * captured by {@link #sendPrompt(String)}. */ private void writePty(byte[] bytes) throws IOException { + if (connector == null) { + // No PTY yet (the launch is still being prepared): drop the write, + // as sendPrompt() does. + LOG.fine("[PTY write] dropped, no connector"); + return; + } boolean previous = pluginPtyWrite.get(); pluginPtyWrite.set(true); try { diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 17a4a8d6..920b6e10 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -271,11 +271,40 @@ public static List listSessionIdsWrittenSince(Path workingDir, Path clau public static boolean containsUserPromptSince(Path workingDir, Path claudeConfigDir, String sessionId, long sinceMillis, String prompt) { - if (sessionId == null || prompt == null || prompt.isBlank()) return false; - Path file = sessionsDir(workingDir, claudeConfigDir).resolve(sessionId + ".jsonl"); - if (!Files.isRegularFile(file)) return false; + if (prompt == null || prompt.isBlank()) return false; String wanted = prompt.trim(); if (wanted.isEmpty()) return false; + return containsUserRecordSince(workingDir, claudeConfigDir, sessionId, sinceMillis, + line -> isUserMessageWith(line, sinceMillis, wanted)); + } + + /** + * Returns whether the session file contains a user message written at or + * after {@code sinceMillis}, whatever its text. + * + *

    Used when the submitted prompt has no known text (it was recalled from + * the TUI history): a user record written after that submission is evidence + * that a prompt went into the file, while another tab's turn only appends + * assistant records, so a newer file time alone is not. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sessionId session ID (file name without {@code .jsonl}) + * @param sinceMillis earliest acceptable message timestamp (epoch millis) + * @return {@code true} if such a message exists; {@code false} otherwise + */ + public static boolean containsUserMessageSince(Path workingDir, Path claudeConfigDir, + String sessionId, long sinceMillis) { + return containsUserRecordSince(workingDir, claudeConfigDir, sessionId, sinceMillis, + line -> isUserMessageSince(line, sinceMillis)); + } + + private static boolean containsUserRecordSince(Path workingDir, Path claudeConfigDir, + String sessionId, long sinceMillis, + java.util.function.Predicate match) { + if (sessionId == null) return false; + Path file = sessionsDir(workingDir, claudeConfigDir).resolve(sessionId + ".jsonl"); + if (!Files.isRegularFile(file)) return false; try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(file.toFile(), "r")) { // Read backwards in blocks: the newest records are the most likely // match, and a long turn can push the prompt arbitrarily far from @@ -321,7 +350,7 @@ public static boolean containsUserPromptSince(Path workingDir, Path claudeConfig String text = joined.toString(StandardCharsets.UTF_8); String[] lines = text.split("\n", -1); for (int i = lines.length - 1; i >= 0; i--) { - if (isUserMessageWith(lines[i], sinceMillis, wanted)) return true; + if (match.test(lines[i])) return true; if (isOlderThan(lines[i], sinceMillis)) return false; } } @@ -357,6 +386,20 @@ private static boolean isOlderThan(String line, long cutoffMillis) { } } + /** 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; + return Instant.parse(tsNode.asText()).toEpochMilli() >= sinceMillis; + } catch (Exception e) { + 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; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index a553e9c9..e5cd1c86 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2640,6 +2640,72 @@ void aSiblingBorrowingAProvisionalClaimDoesNotDisplaceIt() throws Exception { } } + /** + * 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. From 1c6179d81dbc8a539e7b0ea9320780e1896fa688 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 16:55:10 +0300 Subject: [PATCH 036/111] Fixed prompt evidence, Tab completion and Default profile config resolution - A type "user" JSONL record is evidence of a human prompt only when it carries text: tool results are also stored as type "user" while Claude works, and system-injected records are marked isMeta. Accepting them let a sibling tab finishing a tool look like a submission and adopt that tab's conversation on save. - A live Tab makes the TUI complete the input with text the parser never sees, so the recorded line is only a prefix. Submitting it now records unknown prompt text instead of the prefix, which keeps the file-time fallback in charge of identifying a shared conversation. - The pending launch resolves its effective config dir for the Default profile too: the Default profile's extra environment variables may set CLAUDE_CONFIG_DIR, and the launched process honors it. Save & Switch opened during launch preparation now lists the same store the replacement process will use. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 34 +++++++++---- .../process/ClaudeSessionStore.java | 22 ++++++++- .../claudecodegui/ui/ClaudeSessionTab.java | 18 +++---- .../ClaudeSessionControllerTest.java | 30 ++++++++++++ .../process/ClaudeSessionStoreTest.java | 49 +++++++++++++++++++ .../ui/ClaudeSessionTabPendingLaunchTest.java | 12 +++-- 6 files changed, 142 insertions(+), 23 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 69b30f04..2aeb372b 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -308,7 +308,14 @@ private static final class SessionClaims { */ private boolean terminalHistoryRecall; - /** Set when Enter submitted a history-recalled line the parser did not record. */ + /** + * 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; /** Screen-poll ticks since the last background session-identity scan. */ @@ -1178,6 +1185,7 @@ private void resetTerminalInputRecording() { terminalPasteAfterCR = false; terminalLineEntered = false; terminalHistoryRecall = false; + terminalCompletionEdited = false; terminalRecalledSubmission = false; } } @@ -2992,16 +3000,18 @@ private String appendTerminalInput(byte[] bytes) { } } else if (c == '\r' || c == '\n') { String line = terminalInputLine.toString().trim(); - boolean recalled = terminalHistoryRecall; + boolean unknownText = terminalHistoryRecall || terminalCompletionEdited; terminalHistoryRecall = false; + terminalCompletionEdited = false; terminalInputLine.setLength(0); terminalInputCursor = 0; terminalLineEntered = true; - if (recalled) { - // The line was filled from the TUI history (Up/Down) 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. + if (unknownText) { + // 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. terminalRecalledSubmission = true; } else if (!line.isEmpty()) { submitted = line; @@ -3011,9 +3021,15 @@ private String appendTerminalInput(byte[] bytes) { } else if (c == 0x03 || c == 0x15) { // Ctrl+C, Ctrl+U terminalInputLine.setLength(0); terminalInputCursor = 0; - // Clearing the input also discards a recalled prompt, so what - // is typed next is known in full again. + // 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. + terminalCompletionEdited = true; } else if (c >= 0x20) { insertTerminalChar(c); } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 920b6e10..15d596d9 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -394,12 +394,32 @@ private static boolean isUserMessageSince(String line, long sinceMillis) { if (!"user".equals(node.path("type").asText())) return false; JsonNode tsNode = node.get("timestamp"); if (tsNode == null || !tsNode.isTextual()) return false; - return Instant.parse(tsNode.asText()).toEpochMilli() >= sinceMillis; + 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; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index e8c663a1..6a3baba4 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -846,11 +846,10 @@ 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)) { - configDir = resolveLaunchConfigDir(ClaudeProfileStore.findByName(profileName), - 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. @@ -1374,8 +1373,8 @@ private File currentOrPendingDirectory() { /** * Effective Claude config dir matching {@link #currentOrPendingDirectory()}. * - * @return the running session's config dir, else the pending launch's, - * else {@code null} for the default store + * @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(); @@ -1393,10 +1392,11 @@ private Path currentOrPendingConfigDir() { * * @param profile profile of the pending launch; may be {@code null} * @param profilesDir base directory for profile config dirs - * @return the effective config dir, or {@code null} for the default store + * @return the effective config dir; the default store when nothing else is configured */ static Path resolveLaunchConfigDir(ClaudeProfile profile, Path profilesDir) { - if (profile == null || profile.isDefault()) return null; + // 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); } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index e5cd1c86..4549e81b 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2324,6 +2324,36 @@ void aTabInsideABracketedPasteIsRecordedAsTyped() { paste.getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS)); } + /** + * Live Tab makes the TUI complete the input from its own suggestions; the + * completed text never reaches this parser. The recorded line is therefore + * only a prefix of the submitted prompt, so the text must be treated as + * unknown instead of being matched against the JSONL. + */ + @Test + void aTerminalCompletedPromptHasUnknownText() { + controller.simulateTerminalInput( + "inspect @REA".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 1L); + + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 2L), + "the prefix typed before Tab is not the whole submitted prompt"); + } + + /** Ctrl+U after a completion discards it, so the next prompt is known again. */ + @Test + void clearingACompletedPromptMakesTheNextSubmissionKnown() { + controller.simulateTerminalInput( + "inspect @REA".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 1L); + controller.simulateTerminalInput(new byte[]{0x15}, START_MS + 2L); + + assertEquals("typed after clearing", controller.simulateTerminalInput( + "typed after clearing\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 3L)); + } + /** * While a choice menu is open, Up/Down move the menu cursor and Enter * selects the item; the typed line stays empty just as it does for a prompt diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index d9fb29e6..5ba1cef3 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -471,6 +471,55 @@ void containsUserPromptSince_noEvidence_returnsFalse() throws IOException { OLD_TS.toEpochMilli(), " ")); } + /** + * Claude stores tool results as {@code type: "user"} records while it + * works. A sibling tab finishing a tool must not look like a human prompt + * submitted in this tab. + */ + @Test + void containsUserMessageSince_ignoresToolResults() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[" + + "{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\"," + + "\"content\":\"file contents\"}]},\"timestamp\":\"" + + OLD_TS.plusSeconds(60) + "\"}"); + + assertFalse(ClaudeSessionStore.containsUserMessageSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli()), + "a tool result is written by Claude, not by the human"); + } + + /** A system-injected (meta) user record is not a human prompt either. */ + @Test + void containsUserMessageSince_ignoresMetaRecords() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, "{\"type\":\"user\",\"isMeta\":true,\"message\":{\"role\":\"user\"," + + "\"content\":\"injected context\"},\"timestamp\":\"" + + OLD_TS.plusSeconds(60) + "\"}"); + + assertFalse(ClaudeSessionStore.containsUserMessageSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli())); + } + + @Test + void containsUserMessageSince_acceptsHumanText() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + writeJsonl(file, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"a typed prompt\"}]},\"timestamp\":\"" + + OLD_TS.plusSeconds(60) + "\"}"); + + assertTrue(ClaudeSessionStore.containsUserMessageSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli())); + } + @Test void findSession_returnsRequestedSessionOnly() throws IOException { Path workDir = tmpDir.resolve("myproject"); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java index 71b9984a..2f1e74e9 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -177,10 +177,14 @@ void pendingLaunchConfigDirAppliesTheProfilesEnvironmentOverride() throws Except ClaudeSessionTab.resolveLaunchConfigDir(profile, profiles), "the environment override must win over the storage dir"); - assertNull(ClaudeSessionTab.resolveLaunchConfigDir( - io.github.nbplugins.claudecodegui.settings.ClaudeProfile.createDefault(), - profiles), - "the default profile keeps the default store"); + io.github.nbplugins.claudecodegui.settings.ClaudeProfile fallback = + io.github.nbplugins.claudecodegui.settings.ClaudeProfile.createDefault(); + Path defaultOverride = profiles.resolve("default-override-store"); + fallback.setExtraEnvVars(java.util.Collections.singletonList( + new String[]{"CLAUDE_CONFIG_DIR", defaultOverride.toString()})); + assertEquals(defaultOverride, + ClaudeSessionTab.resolveLaunchConfigDir(fallback, profiles), + "the default profile's environment override must be honored too"); } finally { profiles.toFile().delete(); } From 5293c53bf4497921ce253d257fb8bfda734a5333 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 17:17:35 +0300 Subject: [PATCH 037/111] Fixed menu Tab being taken as completion and quadratic paste insertion - A live Tab only marks the draft as completion-edited when no choice menu is open: while the menu is open Tab belongs to it ("Tab to amend") and the following Enter selects an item rather than submitting a prompt. - Inside a bracketed paste each contiguous run of text is now inserted in one operation. Inserting character by character rewrote the entire unchanged suffix for every character, so a 512 KiB paste before a 512 KiB draft blocked terminal writes for seconds (about 5.5 s in a test, versus milliseconds when pasting at the end). Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 31 +++++++- .../ClaudeSessionControllerTest.java | 79 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 2aeb372b..684d44fb 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -2995,7 +2995,20 @@ private String appendTerminalInput(byte[] bytes) { } else if (c >= 0x20 || c == '\t') { // Tab is literal text in a paste (Claude matches the prompt // exactly), while the same key typed live completes it. - insertTerminalChar(c); + // Insert the whole contiguous run at once: inserting one + // character at a time would copy the still-unchanged suffix + // of the draft for every character, which makes a paste + // before long existing text quadratic. The run stops before + // a control character, so a terminal escape sequence (the + // end of the paste) is still parsed on its own. + int end = i + 1; + while (end < text.length()) { + char next = text.charAt(end); + if (next != '\t' && next < 0x20) break; + end++; + } + insertTerminalText(text.subSequence(i, end)); + i = end - 1; terminalPasteAfterCR = false; } } else if (c == '\r' || c == '\n') { @@ -3028,8 +3041,13 @@ private String appendTerminalInput(byte[] bytes) { } 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. - terminalCompletionEdited = true; + // 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 (model.getActiveChoiceMenu() == null) { + terminalCompletionEdited = true; + } } else if (c >= 0x20) { insertTerminalChar(c); } @@ -3047,6 +3065,13 @@ private void insertTerminalChar(char 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. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 4549e81b..98ce1092 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2354,6 +2354,85 @@ void clearingACompletedPromptMakesTheNextSubmissionKnown() { START_MS + 3L)); } + /** + * While a choice menu is open, Tab acts on the menu ("Tab to amend") and + * Enter selects an item rather than submitting a prompt. Marking that Tab + * as a completion would record the selection as an unknown-text prompt and + * let the adoption pass take a sibling's newer turn. + */ + @Test + void aTabInAChoiceMenuDoesNotRecordAPrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + model.setActiveChoiceMenu(new ChoiceMenuModel("Pick one", List.of( + new ChoiceMenuModel.Option("Yes", "1"), + new ChoiceMenuModel.Option("No", "2")), 0)); + // The sibling submits its own turn while the menu is open. + appendUserPrompt(dir, "shared", "sibling turn", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // Tab acts on the menu, Enter selects the item. + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 11_000L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 11_001L); + + assertEquals("own", controller.resolveActiveSessionId(), + "a menu Tab and its selection are not a submitted prompt"); + } finally { + model.clearChoiceMenu(); + tabB.stopProcess(); + } + } + + /** + * 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)"); + } + /** * 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 From 148f79cfdcd8bcfb42bab2a3db12c1b600533902 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 17:41:12 +0300 Subject: [PATCH 038/111] Fixed shared-message evidence order and Esc sequence-prefix misparsing - For an unknown-text prompt, the shared user-record check now runs before the file-time fallback. An unrelated tab that wrote its own conversation after the submission and then exited is unclaimed, so its newer mtime used to win and the resumed conversation was never found; Save & Switch then renamed the exited tab's session. - A standalone Esc is only standalone when it ends its write, so the next write starts actual input even if it begins with '[' or 'O': a real CSI/SS3 sequence always arrives in one piece. Those characters had been swallowed as sequence prefixes, dropping them from the recorded prompt. A fresh Esc at the start of a write no longer inherits the carried flag, so a key sequence sent right after a standalone Esc still parses. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 41 +++++++---- .../ClaudeSessionControllerTest.java | 73 +++++++++++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 684d44fb..041ebf0a 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -951,17 +951,17 @@ boolean refreshSessionIdentity() { promptAt, promptText); } } - if (newest == null) { - newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( - dir.toPath(), configDir, since, exclude); - } - if (newest == null && promptAt > 0 && promptAt >= externalAt + if (promptAt > 0 && promptAt >= externalAt && (promptText == null || promptText.isBlank())) { - // A prompt recalled from the TUI history: its text is unknown, so - // no file can match it as evidence. The conversation another live - // tab holds that this process switched into must then show a user - // record written at or after the submission — a merely newer file - // time is that tab's own turn, not evidence of a switch. + // A prompt recalled from the TUI history or completed by Tab: + // its text is unknown, so no file can match it as evidence. The + // conversation another live tab holds that this process switched + // into must then show a user record written at or after the + // submission — a merely newer file time is that tab's own turn, + // not evidence of a switch. This must run before the file-time + // fallback: a third tab that wrote its own conversation after + // the submission and then exited is unclaimed, and its newer + // mtime would otherwise hide the resumed conversation. Set shared = new HashSet<>(claimed); shared.addAll(otherLiveClaims); if (activeId != null) shared.add(activeId); @@ -974,6 +974,10 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); } } + if (newest == null) { + newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( + dir.toPath(), configDir, since, exclude); + } if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { // Coarse filesystem timestamps can round a file written right // after the prompt below its cutoff. Retry with a slack so a @@ -2953,22 +2957,27 @@ private String appendTerminalInput(byte[] bytes) { terminalInputEscape = 1; terminalEscapeParams.setLength(0); terminalPasteAfterCR = false; + // 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 the character that followed belongs to the input, so it - // is processed again as ordinary input. - if (c == '[') { + // — 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--; + } else if (c == '[') { terminalInputEscape = 2; } else if (c == 'O') { terminalInputEscape = 3; } else { terminalInputEscape = 0; - if (carriedEscape) { - i--; - } } carriedEscape = false; } else if (terminalInputEscape == 2) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 98ce1092..008047e6 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2304,6 +2304,57 @@ void anEditedHistoryRecallKeepsThePromptTextUnknown() throws Exception { } } + /** + * An unknown-text prompt switched into another live tab's conversation. + * A third tab then writes its own conversation and exits: its file is + * unclaimed and newer than the resumed one, but the resumed file holds the + * submitted user record. The evidence must win over the bare file time, or + * Save & Switch renames the unrelated conversation. + */ + @Test + void anUnknownPromptAdoptsTheResumedConversationOverANewerExitedOne() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + writeSession(dir, "third", "third"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("third.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + // The resumed conversation receives the submitted prompt. + appendUserPrompt(dir, "shared", "recalled prompt", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + // An unrelated, already-exited session is written even later. + setMtime(dir.resolve("third.jsonl"), START_MS + 13_000L); + + // Up recalls a prompt and Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + assertEquals("shared", controller.resolveActiveSessionId(), + "the shared user record must beat a newer unclaimed file"); + } finally { + tabB.stopProcess(); + } + } + /** Clearing the input with Ctrl+U discards a recalled prompt: the next text is known. */ @Test void clearingARecalledPromptMakesTheNextSubmissionKnown() { @@ -2901,6 +2952,28 @@ void aStandaloneEscapeDoesNotSwallowTheNextKeystroke() { "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 From 41ea702f7943c394e74cde5b77054db73ae1c268 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 22:45:09 +0300 Subject: [PATCH 039/111] Fixed own-session evidence for unknown prompts and rounded cutoffs - When the submitted prompt's text is unknown, this tab's own new file is now checked for a user record before another live tab's file is taken as evidence of a conversation switch: an unresolved NEW tab writes its file unclaimed, while a sibling's record around the same time may just be its own turn. Adopting the sibling pinned the tab to that conversation and Save & Switch renamed it. - The exact-text own search now retries with the mtime slack before the borrowed match is accepted. With second-rounded file times a file that already holds the submitted prompt can sit just below the cutoff; the borrowed pass then took a sibling that submitted identical text, and the relaxed passes never ran because a result was already found. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 103 ++++++++++++++---- .../ClaudeSessionControllerTest.java | 98 +++++++++++++++++ 2 files changed, 181 insertions(+), 20 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 041ebf0a..b49ccaba 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -924,8 +924,6 @@ boolean refreshSessionIdentity() { // another tab's conversation. Set ownExclude = new HashSet<>(exclude); ownExclude.addAll(otherOwned); - Set own = new HashSet<>(ClaudeSessionStore.listSessionIdsWrittenSince( - dir.toPath(), configDir, since, ownExclude, PROMPT_EVIDENCE_CANDIDATES)); // A session another live tab established is not this tab's own, // even when it is the identity currently tracked (a false // adoption). A merely borrowed or provisionally claimed file @@ -939,8 +937,18 @@ boolean refreshSessionIdentity() { // merely swap the two conversations. String ownPreferred = activeId != null && !otherOwned.contains(activeId) ? activeId : null; - newest = findPromptBearingSession(dir, configDir, own, ownPreferred, - promptAt, promptText); + newest = findPromptBearingSession(dir, configDir, since, ownExclude, + ownPreferred, promptAt, promptText); + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse file times can round this tab's own file below the + // cutoff even though it already holds the prompt. Its record + // still proves the conversation, so retry with the slack + // before yielding to a sibling's file that merely carries + // the same text. + newest = findPromptBearingSession(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, ownExclude, + ownPreferred, promptAt, promptText); + } if (newest == null) { // Only then may a conversation another live tab writes be // adopted: that is an in-process /resume from this terminal. @@ -955,23 +963,37 @@ boolean refreshSessionIdentity() { && (promptText == null || promptText.isBlank())) { // A prompt recalled from the TUI history or completed by Tab: // its text is unknown, so no file can match it as evidence. The - // conversation another live tab holds that this process switched - // into must then show a user record written at or after the - // submission — a merely newer file time is that tab's own turn, - // not evidence of a switch. This must run before the file-time - // fallback: a third tab that wrote its own conversation after - // the submission and then exited is unclaimed, and its newer - // mtime would otherwise hide the resumed conversation. - Set shared = new HashSet<>(claimed); - shared.addAll(otherLiveClaims); - if (activeId != null) shared.add(activeId); - newest = newestSharedSessionWrittenSince(dir, configDir, since, - promptAt, shared, exclude); + // user record is what proves a file was written for this + // submission. + // + // This tab's own new conversation must win over a sibling: an + // unresolved NEW tab writes its file without any other tab + // claiming it, while a sibling's record submitted around the + // same time may just be that tab's own turn. + newest = newestSessionWithUserMessageSince(dir, configDir, since, + promptAt, exclude); if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { - // Coarse file times can round the write below the submission; - // the record's own timestamp still has to match. - newest = newestSharedSessionWrittenSince(dir, configDir, - since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); + newest = newestSessionWithUserMessageSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, exclude); + } + if (newest == null) { + // Only then may a conversation another live tab holds be + // adopted: that is an in-process /resume from this terminal. + // This must run before the file-time fallback: a third tab + // that wrote its own conversation after the submission and + // then exited is unclaimed, and its newer mtime would + // otherwise hide the resumed conversation. + Set shared = new HashSet<>(claimed); + shared.addAll(otherLiveClaims); + if (activeId != null) shared.add(activeId); + newest = newestSharedSessionWrittenSince(dir, configDir, since, + promptAt, shared, exclude); + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse file times can round the write below the submission; + // the record's own timestamp still has to match. + newest = newestSharedSessionWrittenSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); + } } } if (newest == null) { @@ -1014,6 +1036,22 @@ boolean refreshSessionIdentity() { * * @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}. + * + * @param preferred session ID to check first; may be {@code null} + */ + private String findPromptBearingSession(java.io.File dir, java.nio.file.Path configDir, + long mtimeCutoff, Set exclude, + String preferred, long promptAt, String promptText) { + Set candidates = new HashSet<>(ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, mtimeCutoff, exclude, PROMPT_EVIDENCE_CANDIDATES)); + return findPromptBearingSession(dir, configDir, candidates, preferred, promptAt, + promptText); + } + private String findPromptBearingSession(java.io.File dir, java.nio.file.Path configDir, Set candidates, String preferred, long promptAt, String promptText) { @@ -1030,6 +1068,31 @@ && containsPrompt(dir, configDir, sessionId, promptAt, promptText)) { return null; } + /** + * Returns the newest session file written at or after {@code mtimeCutoff}, + * skipping {@code exclude}, that holds a user message at or after + * {@code promptCutoff}, or {@code null}. + * + *

    Used when the submitted prompt's text is unknown: a user record is + * then the only evidence that a file was written for this submission, and + * unlike {@link #newestSharedSessionWrittenSince} the file does not have to + * belong to another live tab. + */ + private static String newestSessionWithUserMessageSince(java.io.File dir, + java.nio.file.Path configDir, + long mtimeCutoff, + long promptCutoff, + Set exclude) { + for (String id : ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, mtimeCutoff, exclude, PROMPT_EVIDENCE_CANDIDATES)) { + if (ClaudeSessionStore.containsUserMessageSince( + dir.toPath(), configDir, id, promptCutoff)) { + return id; + } + } + return null; + } + /** * Returns the newest session ID in {@code shared} whose file was written at * or after {@code mtimeCutoff} and holds a user message at or after diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 008047e6..8f395f78 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2355,6 +2355,104 @@ void anUnknownPromptAdoptsTheResumedConversationOverANewerExitedOne() throws Exc } } + /** + * An unresolved NEW tab submits a Tab-completed prompt, so its text is + * unknown, while a live sibling submits its own human prompt. The sibling + * holds the only claimed file, but this tab's own new file also contains a + * user record for its submission: taking the sibling's record as a switch + * would pin this tab to the sibling's conversation. + */ + @Test + void anUnknownPromptPrefersTheTabsOwnNewSessionOverASiblingTurn() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "sibling", "sibling"); + setMtime(dir.resolve("sibling.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("sibling")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "sibling", Set.of("sibling")); + + try { + // Tab completes the prompt, Enter submits it: the text is unknown. + controller.simulateTerminalInput( + "inspect @REA".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 11_001L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_002L)); + + // This tab's own new conversation gets the submission ... + writeSession(dir, "own-new", "own-new"); + appendUserPrompt(dir, "own-new", "inspect @README.md", START_MS + 11_500L); + setMtime(dir.resolve("own-new.jsonl"), START_MS + 13_000L); + // ... while the sibling submits an unrelated prompt of its own. + appendUserPrompt(dir, "sibling", "sibling turn", START_MS + 11_400L); + setMtime(dir.resolve("sibling.jsonl"), START_MS + 12_000L); + + assertEquals("own-new", controller.resolveActiveSessionId(), + "this tab's own record must beat the sibling's own turn"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A file with second-rounded mtimes can hold the complete submitted prompt + * yet sit just below the exact cutoff. A live sibling submitting identical + * text must not then be adopted: the relaxed cutoff has to be applied to + * this tab's own candidates before the borrowed match is accepted. + */ + @Test + void aRoundedOwnFileStillWinsOverASiblingWithTheSamePrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "sibling", "sibling"); + setMtime(dir.resolve("sibling.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("sibling")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "sibling", Set.of("sibling")); + + try { + assertEquals("hello", controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L), + "the submitted prompt is recorded exactly"); + + // The own file holds the prompt but its rounded mtime is below the + // submission; the sibling submitted the same text afterwards. + writeSession(dir, "own-new", "own-new"); + appendUserPrompt(dir, "own-new", "hello", START_MS + 11_000L); + setMtime(dir.resolve("own-new.jsonl"), START_MS + 10_500L); + appendUserPrompt(dir, "sibling", "hello", START_MS + 11_200L); + setMtime(dir.resolve("sibling.jsonl"), START_MS + 12_000L); + + assertEquals("own-new", controller.resolveActiveSessionId(), + "the own record must be found before the borrowed match"); + } finally { + tabB.stopProcess(); + } + } + /** Clearing the input with Ctrl+U discards a recalled prompt: the next text is known. */ @Test void clearingARecalledPromptMakesTheNextSubmissionKnown() { From 0888b7b875bc838922d4aafe8d717f29ee238f54 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 05:56:01 +0300 Subject: [PATCH 040/111] Fixed shared resume identity and the dialog store after a profile move - A tab whose conversation was known at launch (--continue/--resume) now keeps that identity preferred over an unrelated file carrying the same prompt text, even when a second live tab resumed the same saved session and only borrows the file. Previously the borrowed claim made the scan treat the shared file as another tab's and adopt a third conversation. - The Save & Switch dialog now lists conversations from the store the replacement process will use: restarting reloads the selected profile from the profile store, so a storage directory changed in Options since the session started applies to it. The running session's name is still read from the directory captured at launch. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 9 +++- .../claudecodegui/ui/ClaudeSessionTab.java | 34 ++++++++++-- .../ClaudeSessionControllerTest.java | 54 +++++++++++++++++++ .../ui/ClaudeSessionTabPendingLaunchTest.java | 29 ++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index b49ccaba..1657bb95 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -935,7 +935,14 @@ boolean refreshSessionIdentity() { // unresolved NEW tabs submitting identical text, the evidence // is indistinguishable, and displacing the current claim would // merely swap the two conversations. - String ownPreferred = activeId != null && !otherOwned.contains(activeId) + // + // A launch-known identity (--continue/--resume) is this tab's + // own conversation even when another live tab shares the same + // saved file (both resumed it, so one of them only borrows it): + // it must stay preferred, or an unrelated file carrying the + // same prompt text would be adopted in its place. + String ownPreferred = activeId != null + && (launchIdentityKnown || !otherOwned.contains(activeId)) ? activeId : null; newest = findPromptBearingSession(dir, configDir, since, ownExclude, ownPreferred, promptAt, promptText); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 6a3baba4..2dbbc4c6 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -1284,17 +1284,18 @@ public void openSwitchDialog(SessionMode initialMode) { if (workingDir == null) return; // The controller captured the effective config dir and session ID at launch, - // so the dialog lists/prefills the same store the running process uses. - // While a launch is pending, its config dir is the one the process will use. - Path claudeConfigDir = currentOrPendingConfigDir(); + // 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(), claudeConfigDir, currentId) + ? 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, @@ -1400,6 +1401,29 @@ static Path resolveLaunchConfigDir(ClaudeProfile profile, Path profilesDir) { 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 = currentOrPendingDirectory(); String profileName = selectorPanel.getSelectedProfileName(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 8f395f78..49b2f484 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3010,6 +3010,60 @@ void anExplicitResumeOwnsTheFileEvenWhenANewTabGuessedItFirst() throws Exception } } + /** + * 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java index 2f1e74e9..e6a0c429 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPendingLaunchTest.java @@ -190,6 +190,35 @@ void pendingLaunchConfigDirAppliesTheProfilesEnvironmentOverride() throws Except } } + /** + * The Save & Switch dialog lists conversations for the replacement + * launch, which reloads the profile from the store. If the profile's + * storage directory was changed in Options while the session was running, + * the dialog must follow the new directory — the same store the restart + * will write to — instead of the one captured when the session started. + */ + @Test + void saveAndSwitchListsTheStoreTheReplacementLaunchWillUse() throws Exception { + Path profiles = Files.createTempDirectory("dialog-config-test"); + try { + io.github.nbplugins.claudecodegui.settings.ClaudeProfile profile = + io.github.nbplugins.claudecodegui.settings.ClaudeProfile.createNamed("Work"); + Path runningDir = profiles.resolve("old-store"); + profile.setStorageDir(runningDir.toString()); + assertEquals(runningDir, + ClaudeSessionTab.dialogSessionListConfigDir(profile, profiles, runningDir), + "an unchanged profile lists the store the running session uses"); + + Path movedDir = profiles.resolve("new-store"); + profile.setStorageDir(movedDir.toString()); + assertEquals(movedDir, + ClaudeSessionTab.dialogSessionListConfigDir(profile, profiles, runningDir), + "a storage dir changed in Options must be listed by the dialog"); + } finally { + profiles.toFile().delete(); + } + } + private static String pathToRestore(ClaudeSessionTab tab) throws Exception { Field f = ClaudeSessionTab.class.getDeclaredField("pathToRestore"); f.setAccessible(true); From 4ff56acd5d3429fd0d406cfb6967230174074a85 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 06:49:24 +0300 Subject: [PATCH 041/111] Fixed unknown-prompt identity for launch-known tabs and shared candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The own-new-file priority for unknown-text prompts (history recalls and Tab-completed prompts) now applies only to NEW/provisional launches. A tab whose conversation was known at launch had nothing new to create, so an unrelated unclaimed human record — e.g. an exited third tab's own prompt written after this tab's submission — could displace the shared conversation the tab switched into, and Save & Switch renamed that record. - The shared-conversation evidence pass now filters candidates to the shared session IDs before applying the eight-file limit, so a crowd of newer unrelated files can no longer hide the destination that holds the submission; the store gained an include-set overload for this. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 31 +++-- .../process/ClaudeSessionStore.java | 25 +++++ .../ClaudeSessionControllerTest.java | 106 ++++++++++++++++++ .../process/ClaudeSessionStoreTest.java | 17 +++ 4 files changed, 168 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 1657bb95..31f209e5 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -973,15 +973,20 @@ boolean refreshSessionIdentity() { // user record is what proves a file was written for this // submission. // - // This tab's own new conversation must win over a sibling: an - // unresolved NEW tab writes its file without any other tab - // claiming it, while a sibling's record submitted around the - // same time may just be that tab's own turn. - newest = newestSessionWithUserMessageSince(dir, configDir, since, - promptAt, exclude); - if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { - newest = newestSessionWithUserMessageSince(dir, configDir, - since - SESSION_MTIME_GRANULARITY_MS, promptAt, exclude); + // Only an unresolved NEW tab gets the new-file priority: it + // writes its own file without any other tab claiming it, while + // a sibling's record submitted around the same time may just be + // that tab's own turn. A tab whose conversation was known at + // launch had nothing new to create, so an unrelated unclaimed + // human record (e.g. an exited third tab's own prompt) must not + // displace the shared conversation it switched into. + if (!launchIdentityKnown) { + newest = newestSessionWithUserMessageSince(dir, configDir, since, + promptAt, exclude); + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + newest = newestSessionWithUserMessageSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, exclude); + } } if (newest == null) { // Only then may a conversation another live tab holds be @@ -1120,9 +1125,13 @@ private static String newestSharedSessionWrittenSince(java.io.File dir, if (shared.isEmpty()) return null; Set sharedExclude = new HashSet<>(exclude); sharedExclude.removeAll(shared); + // Only the shared conversations may be considered: newer unrelated + // files must not fill the limited candidate list and hide the + // destination that holds this tab's submission. for (String id : ClaudeSessionStore.listSessionIdsWrittenSince( - dir.toPath(), configDir, mtimeCutoff, sharedExclude, PROMPT_EVIDENCE_CANDIDATES)) { - if (shared.contains(id) && ClaudeSessionStore.containsUserMessageSince( + dir.toPath(), configDir, mtimeCutoff, sharedExclude, shared, + PROMPT_EVIDENCE_CANDIDATES)) { + if (ClaudeSessionStore.containsUserMessageSince( dir.toPath(), configDir, id, promptCutoff)) { return id; } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 15d596d9..4635158d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -220,6 +220,30 @@ public static String findLatestSessionIdWrittenSince(Path workingDir, Path claud public static List listSessionIdsWrittenSince(Path workingDir, Path claudeConfigDir, long sinceMillis, Set excludeIds, int limit) { + return listSessionIdsWrittenSince(workingDir, claudeConfigDir, sinceMillis, + excludeIds, null, limit); + } + + /** + * Returns up to {@code limit} IDs of session files modified at or after + * {@code sinceMillis}, newest first, skipping {@code excludeIds}. + * + *

    When {@code includeIds} is non-{@code null}, only those sessions are + * considered before the limit is applied. A caller that is looking + * for a specific known conversation (e.g. one another tab is writing) must + * not let newer unrelated files fill the limited list and hide it. + * + * @param workingDir working directory + * @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude} + * @param sinceMillis earliest acceptable file modification time (epoch millis) + * @param excludeIds session IDs to ignore; may be {@code null} + * @param includeIds session IDs to consider, or {@code null} for all + * @param limit maximum number of IDs to return + * @return session IDs, newest first; empty if none match + */ + public static List listSessionIdsWrittenSince(Path workingDir, Path claudeConfigDir, + long sinceMillis, Set excludeIds, + Set includeIds, int limit) { if (limit <= 0) return List.of(); Path dir = sessionsDir(workingDir, claudeConfigDir); if (!Files.isDirectory(dir)) return List.of(); @@ -229,6 +253,7 @@ public static List listSessionIdsWrittenSince(Path workingDir, Path clau String name = file.getFileName().toString(); if (!name.endsWith(".jsonl")) continue; String id = sessionIdOf(file); + if (includeIds != null && !includeIds.contains(id)) continue; if (excludeIds != null && excludeIds.contains(id)) continue; long mtime; try { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 49b2f484..aafc5164 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2355,6 +2355,112 @@ void anUnknownPromptAdoptsTheResumedConversationOverANewerExitedOne() throws Exc } } + /** + * A tab whose conversation was known at launch switches to another live + * tab's conversation and submits a recalled prompt. An exited tab wrote its + * own human prompt afterwards, so its file is unclaimed and holds a user + * record; the shared conversation holds this tab's submission. The new-file + * priority belongs to unresolved NEW tabs only, so the unrelated record + * must not pin this tab and make Save & Switch rename it. + */ + @Test + void anUnknownPromptFromALaunchedTabKeepsTheSharedDestination() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + writeSession(dir, "exited", "exited"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("exited.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared", "exited")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared", "exited")); + + try { + // Up recalls a prompt and Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The shared conversation receives the submission. + appendUserPrompt(dir, "shared", "recalled prompt", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + // An exited tab wrote its own human prompt after the submission. + appendUserPrompt(dir, "exited", "unrelated work", START_MS + 11_600L); + setMtime(dir.resolve("exited.jsonl"), START_MS + 13_000L); + + assertEquals("shared", controller.resolveActiveSessionId(), + "the shared submission must win over an unrelated human record"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The destination of a recalled prompt is another live tab's conversation, + * but many newer unclaimed files (assistant output only) were written since + * the submission. The shared conversations must be filtered before the + * candidate list is truncated, or the destination is never inspected and + * the file-time fallback renames an unrelated conversation. + */ + @Test + void aSharedDestinationIsFoundDespiteManyNewerUnclaimedFiles() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "own", "own"); + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("own.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("shared.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "own", Set.of("own", "shared")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "shared", Set.of("own", "shared")); + + try { + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The shared conversation receives the submission. + appendUserPrompt(dir, "shared", "recalled prompt", START_MS + 11_500L); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + + // A crowd of newer, unclaimed conversations with assistant output + // only fills a globally limited candidate list. + for (int i = 1; i <= 8; i++) { + writeSession(dir, "noise" + i, "noise" + i); + setMtime(dir.resolve("noise" + i + ".jsonl"), START_MS + 12_000L + i * 100L); + } + + assertEquals("shared", controller.resolveActiveSessionId(), + "the shared conversations must be filtered before the limit"); + } finally { + tabB.stopProcess(); + } + } + /** * An unresolved NEW tab submits a Tab-completed prompt, so its text is * unknown, while a live sibling submits its own human prompt. The sibling diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 5ba1cef3..dd1ab373 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -324,6 +324,23 @@ void listSessionIdsWrittenSince_returnsNewestFirstAndSkipsExcluded() throws IOEx "files older than the cutoff are not candidates"); } + @Test + void listSessionIdsWrittenSince_appliesIncludedIdsBeforeTheLimit() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeTimedSession(sessionDir, "wanted", OLD_TS, BASE_MTIME + 1_000L); + for (int i = 1; i <= 8; i++) { + writeTimedSession(sessionDir, "noise" + i, OLD_TS, + BASE_MTIME + 1_000L + i * 100L); + } + + assertEquals(java.util.List.of("wanted"), + ClaudeSessionStore.listSessionIdsWrittenSince( + workDir, tmpDir, BASE_MTIME, java.util.Set.of(), + java.util.Set.of("wanted"), 8), + "newer unrelated files must not fill the limited list"); + } + // ------------------------------------------------------------------------- // containsUserPromptSince // ------------------------------------------------------------------------- From 1943b5d03affbde88b2ce0e323786570a86bf6a2 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 07:09:44 +0300 Subject: [PATCH 042/111] Recovered the launch conversation after a shared guess and batched pasted line breaks Kept the session ID the process was launched with (--continue/--resume) after the tracked identity changes. When an unknown-text prompt is submitted, a user record appended to that file after the submission now takes the identity back from a temporary shared guess (a live sibling whose own prompt landed around the same time), so Save & Switch no longer renames the sibling's conversation. The bracketed-paste branch now collects the whole textual run, line breaks included (CRLF normalized across writes), and inserts it once. Handling each line break separately copied the unchanged draft per line, so a 512 KiB paste of "x\n" before a 512 KiB draft blocked the PTY writer for 7.8 s. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 83 ++++++++++++----- .../ClaudeSessionControllerTest.java | 89 +++++++++++++++++++ 2 files changed, 149 insertions(+), 23 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 31f209e5..908bebc0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -231,6 +231,16 @@ private static final class SessionClaims { */ private volatile boolean launchIdentityKnown; + /** + * Session ID the process was launched with ({@code --continue} or + * {@code --resume}), or {@code null} for a {@code NEW} launch. Kept after + * the tracked identity changes: when a prompt submitted later turns out to + * have been appended to the launch conversation, that record proves the + * conversation never actually switched (see + * {@link #refreshSessionIdentity()}). + */ + private volatile String launchSessionId; + /** * Time (epoch millis) a prompt was last submitted from the plugin. Used to * attribute the session file created by a new process to this tab promptly, @@ -405,6 +415,7 @@ void simulateActiveSession(java.nio.file.Path configDir, String sessionId, activeSessionId = sessionId; preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); launchIdentityKnown = sessionId != null; + launchSessionId = sessionId; } /** Package-private for tests: one tick of the periodic identity refresh. */ @@ -598,6 +609,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr preStartSessionIds = launch.preStartSessionIds(); activeSessionId = launch.sessionId(); launchIdentityKnown = activeSessionId != null; + launchSessionId = activeSessionId; PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : "", @@ -750,6 +762,7 @@ public void stopProcess() { activeSessionId = null; preStartSessionIds = Set.of(); launchIdentityKnown = false; + launchSessionId = null; promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; @@ -980,7 +993,19 @@ boolean refreshSessionIdentity() { // launch had nothing new to create, so an unrelated unclaimed // human record (e.g. an exited third tab's own prompt) must not // displace the shared conversation it switched into. - if (!launchIdentityKnown) { + // + // Its launch conversation is different: a user record appended + // to that file after the submission proves the submission went + // there, so a temporary shared guess (a sibling whose own prompt + // landed around the same time) is taken back as soon as the + // record appears. Without this the released claim would leave + // the tab pinned to the sibling's conversation. + if (launchIdentityKnown && launchSessionId != null + && ClaudeSessionStore.containsUserMessageSince(dir.toPath(), configDir, + launchSessionId, promptAt)) { + newest = launchSessionId; + } + if (newest == null && !launchIdentityKnown) { newest = newestSessionWithUserMessageSince(dir, configDir, since, promptAt, exclude); if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { @@ -3073,31 +3098,43 @@ private String appendTerminalInput(byte[] bytes) { 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. - if (c == '\r') { - insertTerminalChar('\n'); - terminalPasteAfterCR = true; - } else if (c == '\n') { - if (!terminalPasteAfterCR) insertTerminalChar('\n'); - terminalPasteAfterCR = false; - } else if (c >= 0x20 || c == '\t') { - // Tab is literal text in a paste (Claude matches the prompt - // exactly), while the same key typed live completes it. - // Insert the whole contiguous run at once: inserting one - // character at a time would copy the still-unchanged suffix - // of the draft for every character, which makes a paste - // before long existing text quadratic. The run stops before - // a control character, so a terminal escape sequence (the - // end of the paste) is still parsed on its own. - int end = i + 1; - while (end < text.length()) { - char next = text.charAt(end); - if (next != '\t' && next < 0x20) break; + // 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; } - insertTerminalText(text.subSequence(i, end)); + } + if (end > i) { + insertTerminalText(pasted); + terminalPasteAfterCR = afterCR; i = end - 1; - terminalPasteAfterCR = false; } } else if (c == '\r' || c == '\n') { String line = terminalInputLine.toString().trim(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index aafc5164..81698660 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2461,6 +2461,64 @@ void aSharedDestinationIsFoundDespiteManyNewerUnclaimedFiles() throws Exception } } + /** + * A launch-known tab submits a recalled prompt at about the same time as a + * live sibling submits its own human prompt: the shared pass adopts the + * sibling's conversation for a moment. The launch conversation then + * receives this tab's user record and becomes the newest file, so the tab + * must take its identity back instead of staying pinned to the sibling — + * otherwise Save & Switch renames the sibling's conversation. + */ + @Test + void anUnknownPromptRecoversTheLaunchConversationAfterASharedGuess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // Up recalls a prompt and Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's own human prompt lands first. + appendUserPrompt(dir, "b", "sibling prompt", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "with no record of its own yet, the tab follows the sibling"); + + // This tab's submission lands in its launch conversation, later. + appendUserPrompt(dir, "a", "recalled prompt", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation's record must take the identity back"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the recovered conversation"); + assertEquals("b", tabB.resolveActiveSessionId(), + "the sibling keeps its own conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * An unresolved NEW tab submits a Tab-completed prompt, so its text is * unknown, while a live sibling submits its own human prompt. The sibling @@ -2688,6 +2746,37 @@ void aLargePasteBeforeALargeDraftIsInsertedQuickly() { + 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 From 51c00ec459f8ddeea41034826d2627cbaeac035c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 07:20:46 +0300 Subject: [PATCH 043/111] Restricted the launch-known preference to the original launch session The own-prompt pass kept any identity a launch-known tab currently tracked as preferred. After a launch-known tab borrowed a sibling's conversation (both submitted the same text around the same time), that preference pinned it to the sibling even once its own launch conversation held the prompt and was strictly newer, so Save & Switch renamed the sibling's file. The exception now applies only to the session the process was launched with; a borrowed conversation owned by another live tab no longer wins over this tab's own file. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 9 ++- .../ClaudeSessionControllerTest.java | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 908bebc0..cbe8e729 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -954,8 +954,15 @@ boolean refreshSessionIdentity() { // saved file (both resumed it, so one of them only borrows it): // it must stay preferred, or an unrelated file carrying the // same prompt text would be adopted in its place. + // + // That exception belongs to the launch ID only. An identity + // adopted later (a sibling's conversation followed while both + // tabs submitted identical text) is the sibling's, and keeping + // it preferred would pin this tab to it even after its own + // launch conversation holds the prompt and is strictly newer. String ownPreferred = activeId != null - && (launchIdentityKnown || !otherOwned.contains(activeId)) + && (activeId.equals(launchSessionId) + || !otherOwned.contains(activeId)) ? activeId : null; newest = findPromptBearingSession(dir, configDir, since, ownExclude, ownPreferred, promptAt, promptText); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 81698660..1503cc1a 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2519,6 +2519,63 @@ void anUnknownPromptRecoversTheLaunchConversationAfterASharedGuess() throws Exce } } + /** + * The same race with known prompt text. Once the tab has borrowed a + * sibling's conversation, the launch-known preference must not keep it + * there: the borrowed file is owned by another live tab, so its own launch + * conversation — which now holds the submitted text and is strictly newer — + * must take the identity back. + */ + @Test + void aKnownPromptRecoversTheLaunchConversationAfterASharedGuess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's identical prompt lands first. + appendUserPrompt(dir, "b", "continue", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "with no record of its own yet, the tab follows the sibling"); + + // This tab's submission lands in its launch conversation, later. + appendUserPrompt(dir, "a", "continue", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation's record must take the identity back"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the recovered conversation"); + assertEquals("b", tabB.resolveActiveSessionId(), + "the sibling keeps its own conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * An unresolved NEW tab submits a Tab-completed prompt, so its text is * unknown, while a live sibling submits its own human prompt. The sibling From be4432b45c932b240450a0ca5066d9445611864f Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 07:38:01 +0300 Subject: [PATCH 044/111] Kept prompt candidates newest-first and tied launch snapshots to the spawn store findPromptBearingSession() put the store's newest-first session list into a HashSet, so with several unclaimed files holding the same prompt the first match could be an older, exited tab's conversation instead of this tab's own file. The candidates now use a LinkedHashSet that preserves the store order. The launch snapshot is prepared on a worker thread while the spawn re-reads the profile. If the profile's storage moved in Options in between, the snapshot listed the old store, and a conversation in the new store written within the file-time slack could pass the relaxed scan and be adopted as this tab's own. startProcess() now reconciles the prepared identity against the config dir the spawned process will use and recaptures the snapshot when they differ. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 54 ++++++++++++- .../ClaudeSessionControllerTest.java | 78 +++++++++++++++++++ 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index cbe8e729..71c2ba2a 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -575,6 +576,40 @@ static LaunchIdentity prepareLaunchIdentity(java.io.File dir, return new LaunchIdentity(configDir, preStartSessionIds, sessionId); } + /** + * Makes the launch identity describe the store the process will actually + * write. + * + *

    The identity is prepared on a worker thread, while the spawn re-reads + * the profile. If the profile's storage directory or + * {@code CLAUDE_CONFIG_DIR} override changed in between, the prepared + * snapshot lists the old store's sessions: every file in the new store + * would look new, and a conversation written there within the file-time + * slack could be adopted as this tab's own. The snapshot is then + * recaptured against the spawn store before the process starts writing. + * + * @param dir working directory + * @param mode session start mode + * @param resumeSessionId session ID to resume (only used for RESUME_SPECIFIC) + * @param prepared identity prepared earlier; may be {@code null} + * @param spawnConfigDir config dir the process will use, or {@code null} + * when it cannot be resolved + * @return the prepared identity when it already describes + * {@code spawnConfigDir}, otherwise a fresh snapshot of that store + */ + static LaunchIdentity reconcileLaunchIdentity(java.io.File dir, SessionMode mode, + String resumeSessionId, + LaunchIdentity prepared, + java.nio.file.Path spawnConfigDir) { + if (spawnConfigDir == null) return prepared; + if (prepared != null && prepared.configDir() != null + && prepared.configDir().toAbsolutePath().normalize() + .equals(spawnConfigDir.toAbsolutePath().normalize())) { + return prepared; + } + return prepareLaunchIdentity(dir, spawnConfigDir, mode, resumeSessionId); + } + /** * Starts the PTY process using a session identity prepared earlier with * {@link #prepareLaunchIdentity(java.io.File, String, SessionMode, String)}. @@ -603,9 +638,13 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr // 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 = identity != null ? identity - : prepareLaunchIdentity(dir, profileName, effectiveMode, resumeSessionId); + LaunchIdentity launch = reconcileLaunchIdentity(dir, effectiveMode, resumeSessionId, + identity, + ClaudeProcess.resolveConfigDir(profile, ClaudeCodePreferences.getProfilesDir())); preStartSessionIds = launch.preStartSessionIds(); activeSessionId = launch.sessionId(); launchIdentityKnown = activeSessionId != null; @@ -1085,13 +1124,20 @@ boolean refreshSessionIdentity() { * {@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} */ private String findPromptBearingSession(java.io.File dir, java.nio.file.Path configDir, long mtimeCutoff, Set exclude, String preferred, long promptAt, String promptText) { - Set candidates = new HashSet<>(ClaudeSessionStore.listSessionIdsWrittenSince( - dir.toPath(), configDir, mtimeCutoff, exclude, PROMPT_EVIDENCE_CANDIDATES)); + Set candidates = new LinkedHashSet<>( + ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, mtimeCutoff, exclude, + PROMPT_EVIDENCE_CANDIDATES)); return findPromptBearingSession(dir, configDir, candidates, preferred, promptAt, promptText); } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 1503cc1a..2ad6e729 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2519,6 +2519,84 @@ void anUnknownPromptRecoversTheLaunchConversationAfterASharedGuess() throws Exce } } + /** + * Two unclaimed files hold the same prompt: an exited tab's conversation and + * this NEW tab's own file, written later. The store returns the newest + * first; the scan must keep that order and take this tab's own file, or the + * older conversation is renamed and kept as the identity on later scans. + */ + @Test + void anUnclaimedNewerOwnFileWinsOverAnOlderOneWithTheSamePrompt() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "z", "z"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("z.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, false); + controller.simulateLiveSession(config, null, Set.of("a", "z")); + + controller.simulateTerminalInput( + "same".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 11_000L); + assertEquals("same", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The exited tab's conversation already holds the same prompt. + appendUserPrompt(dir, "a", "same", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's own file was written later. + appendUserPrompt(dir, "z", "same", START_MS + 11_600L); + setMtime(dir.resolve("z.jsonl"), START_MS + 12_600L); + + assertEquals("z", controller.resolveActiveSessionId(), + "the newest unclaimed match is this tab's own file"); + assertEquals("z", controller.resolveActiveSessionId(), + "later scans must keep the same file"); + } + + /** + * The launch snapshot is prepared on a worker thread and the spawn re-reads + * the profile. If the profile's storage moved in between, the snapshot + * lists the old store, whose files make every file in the new store look + * new — one written within the file-time slack could then be adopted as + * this tab's own. The snapshot must be recaptured in the spawn store. + */ + @Test + void aProfileMovedDuringPreparationRecapturesTheLaunchSnapshot() throws Exception { + java.nio.file.Path oldConfig = tmpDir.resolve("old-config"); + java.nio.file.Path newConfig = tmpDir.resolve("new-config"); + java.nio.file.Path work = tmpDir.resolve("project"); + + writeSession(sessionDir(oldConfig, work), "old", "old"); + writeSession(sessionDir(newConfig, work), "current", "current"); + + ClaudeSessionController.LaunchIdentity prepared = + ClaudeSessionController.prepareLaunchIdentity( + work.toFile(), oldConfig, + io.github.nbplugins.claudecodegui.model.SessionMode.NEW, null); + assertEquals(Set.of("old"), prepared.preStartSessionIds()); + + ClaudeSessionController.LaunchIdentity same = + ClaudeSessionController.reconcileLaunchIdentity( + work.toFile(), + io.github.nbplugins.claudecodegui.model.SessionMode.NEW, + null, prepared, oldConfig); + assertSame(prepared, same, "an unchanged store keeps the prepared snapshot"); + + ClaudeSessionController.LaunchIdentity moved = + ClaudeSessionController.reconcileLaunchIdentity( + work.toFile(), + io.github.nbplugins.claudecodegui.model.SessionMode.NEW, + null, prepared, newConfig); + assertEquals(newConfig, moved.configDir()); + assertEquals(Set.of("current"), moved.preStartSessionIds(), + "the snapshot must describe the store the process will write"); + } + /** * The same race with known prompt text. Once the tab has borrowed a * sibling's conversation, the launch-known preference must not keep it From 58b0855e5b5dd2110caf7884e5474f9d58849989 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 07:54:01 +0300 Subject: [PATCH 045/111] Ordered borrowed prompt candidates and kept sibling-owned files out of own scans The borrowed-evidence pass searched an unordered set of conversations other live tabs hold, so with several of them containing the submitted text it could adopt an older sibling's file even when another was written strictly later. The candidates now come from the store's newest-first listing. The unknown-prompt own-file pass excluded only files other tabs hold that this tab does not hold itself. After a NEW tab borrowed a sibling's conversation it holds that file too, so the sibling's newer writes were chosen over the tab's own JSONL. The pass now excludes every conversation another live tab established. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 28 ++++- .../ClaudeSessionControllerTest.java | 115 ++++++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 71c2ba2a..3b3f167a 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -967,6 +967,14 @@ boolean refreshSessionIdentity() { // A line submitted from the terminal itself counts as the latest // prompt (same millisecond), so a resume driven entirely from the // keyboard is still followed. + // The tab's own evidence excludes every conversation another live + // tab established: such a file is not this tab's own, even when it + // is the identity currently tracked (a false adoption). A merely + // borrowed or provisionally claimed file does not count: a sibling + // following this tab's conversation must not cost this tab the + // session it established. + Set ownExclude = new HashSet<>(exclude); + ownExclude.addAll(otherOwned); if (promptAt > 0 && promptAt >= externalAt) { // First look for the tab's own conversation: a file nobody else // claims that already holds this prompt. It must win over a @@ -974,8 +982,6 @@ boolean refreshSessionIdentity() { // file appears a moment after the sibling that submitted the // same prompt — adopting the sibling then would pin this tab to // another tab's conversation. - Set ownExclude = new HashSet<>(exclude); - ownExclude.addAll(otherOwned); // A session another live tab established is not this tab's own, // even when it is the identity currently tracked (a false // adoption). A merely borrowed or provisionally claimed file @@ -1018,10 +1024,18 @@ boolean refreshSessionIdentity() { if (newest == null) { // Only then may a conversation another live tab writes be // adopted: that is an in-process /resume from this terminal. + // Order the candidates newest first: several live files can + // hold the same text, and the one written last is the + // conversation the process is on — an older sibling that + // submitted the same prompt must not win. Set borrowed = new HashSet<>(claimed); borrowed.addAll(otherLiveClaims); if (activeId != null) borrowed.add(activeId); - newest = findPromptBearingSession(dir, configDir, borrowed, activeId, + Set ordered = new LinkedHashSet<>( + ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, 0L, null, borrowed, + borrowed.size())); + newest = findPromptBearingSession(dir, configDir, ordered, activeId, promptAt, promptText); } } @@ -1052,11 +1066,15 @@ boolean refreshSessionIdentity() { newest = launchSessionId; } if (newest == null && !launchIdentityKnown) { + // A record is not proof of ownership: a sibling's file the + // tab borrowed earlier is now held by this tab too, so the + // claim-based exclusion no longer hides it. Only a file no + // other live tab established may count as this tab's own. newest = newestSessionWithUserMessageSince(dir, configDir, since, - promptAt, exclude); + promptAt, ownExclude); if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { newest = newestSessionWithUserMessageSince(dir, configDir, - since - SESSION_MTIME_GRANULARITY_MS, promptAt, exclude); + since - SESSION_MTIME_GRANULARITY_MS, promptAt, ownExclude); } } if (newest == null) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 2ad6e729..026d7d1c 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2597,6 +2597,121 @@ void aProfileMovedDuringPreparationRecapturesTheLaunchSnapshot() throws Exceptio "the snapshot must describe the store the process will write"); } + /** + * Two live conversations held by sibling tabs both contain the submitted + * text; the tab must adopt the one written last, which is the conversation + * the process is actually on. Searching an unordered set could pick the + * older sibling instead. + */ + @Test + void borrowedPromptCandidatesAreSearchedNewestFirst() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, false); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "a", Set.of("a")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The older sibling's conversation holds the text first... + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // ...and the newer one holds it too, written strictly later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the live conversation written last is the one on screen"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the same conversation"); + } finally { + tabB.stopProcess(); + tabC.stopProcess(); + } + } + + /** + * A NEW tab submits a recalled prompt and borrows a live sibling's + * conversation while its own file does not exist yet. Once its own file + * does exist, it must win even if the sibling writes again later: the + * borrowed file is established by another live tab, so the claim-based + * exclusion alone (this tab now holds the sibling's file too) would keep + * choosing the sibling. + */ + @Test + void anUnknownPromptPrefersItsOwnNewFileOverTheBorrowedSiblings() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("b")); + + try { + // Up recalls a prompt, Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + appendUserPrompt(dir, "b", "sibling prompt", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the sibling's conversation is borrowed while no own file exists"); + + // This tab's own file appears, then the sibling writes again later. + writeSession(dir, "own", "own"); + appendUserPrompt(dir, "own", "own prompt", START_MS + 11_600L); + setMtime(dir.resolve("own.jsonl"), START_MS + 11_700L); + appendUserPrompt(dir, "b", "sibling again", START_MS + 12_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_700L); + + assertEquals("own", controller.resolveActiveSessionId(), + "the tab's own file must win over the borrowed sibling's"); + assertEquals("own", controller.resolveActiveSessionId(), + "later scans must keep the tab's own file"); + } finally { + tabB.stopProcess(); + } + } + /** * The same race with known prompt text. Once the tab has borrowed a * sibling's conversation, the launch-known preference must not keep it From fd7a3a0ee9329746501ef27d5c8523048427d2c8 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 08:09:14 +0300 Subject: [PATCH 046/111] Kept launch conversations eligible instead of conclusive in identity scans The unknown-prompt launch check selected the launch file whenever it gained a user record, even when another live tab launched the same saved file and wrote its own turn there while this tab had resumed a newer conversation. The launch ID is now just another candidate in the newest-first shared search, so a strictly newer resumed conversation wins. The own-prompt pass excluded every conversation another live tab established, including this tab's own launch file when both tabs resumed it. After a temporary adoption of a sibling's conversation the launch file therefore stayed ineligible and the sibling kept winning. The launch ID is now removed from that exclusion, so its matching record takes the identity back. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 33 +++-- .../ClaudeSessionControllerTest.java | 122 ++++++++++++++++++ 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 3b3f167a..78777148 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -975,6 +975,13 @@ boolean refreshSessionIdentity() { // session it established. Set ownExclude = new HashSet<>(exclude); ownExclude.addAll(otherOwned); + // The launch conversation is this tab's own even when another live + // tab established it (both resumed the same saved file): a + // temporary adoption of a sibling's conversation must not make the + // launch file ineligible, or the sibling keeps winning the search. + if (launchIdentityKnown && launchSessionId != null) { + ownExclude.remove(launchSessionId); + } if (promptAt > 0 && promptAt >= externalAt) { // First look for the tab's own conversation: a file nobody else // claims that already holds this prompt. It must win over a @@ -1055,16 +1062,12 @@ boolean refreshSessionIdentity() { // displace the shared conversation it switched into. // // Its launch conversation is different: a user record appended - // to that file after the submission proves the submission went - // there, so a temporary shared guess (a sibling whose own prompt - // landed around the same time) is taken back as soon as the - // record appears. Without this the released claim would leave - // the tab pinned to the sibling's conversation. - if (launchIdentityKnown && launchSessionId != null - && ClaudeSessionStore.containsUserMessageSince(dir.toPath(), configDir, - launchSessionId, promptAt)) { - newest = launchSessionId; - } + // to that file after the submission suggests the submission + // went there, so the shared search below keeps it eligible. It + // must not be conclusive on its own, though: when another live + // tab launched the same saved file, its own turn also appends a + // human record there, and a TUI resume into a newer conversation + // must still win. if (newest == null && !launchIdentityKnown) { // A record is not proof of ownership: a sibling's file the // tab borrowed earlier is now held by this tab too, so the @@ -1084,9 +1087,19 @@ boolean refreshSessionIdentity() { // that wrote its own conversation after the submission and // then exited is unclaimed, and its newer mtime would // otherwise hide the resumed conversation. + // + // The launch conversation is a candidate too, even when + // another live tab holds it: this tab's submission may have + // gone there. The store's newest-first order then decides + // between it and a conversation the tab resumed into, so a + // shared launch file whose record came from the other live + // tab does not win merely by being the launch file. Set shared = new HashSet<>(claimed); shared.addAll(otherLiveClaims); if (activeId != null) shared.add(activeId); + if (launchIdentityKnown && launchSessionId != null) { + shared.add(launchSessionId); + } newest = newestSharedSessionWrittenSince(dir, configDir, since, promptAt, shared, exclude); if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 026d7d1c..1f91edac 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2712,6 +2712,128 @@ void anUnknownPromptPrefersItsOwnNewFileOverTheBorrowedSiblings() throws Excepti } } + /** + * Two live tabs launched the same saved conversation {@code a}; this tab + * then resumed {@code b} in the TUI and submits a recalled prompt there. + * The other launcher writes its own human message into {@code a}. A record + * in the launch file is not proof that this tab's submission went there, so + * the strictly newer resumed conversation must win. + */ + @Test + void aSharedLaunchFileDoesNotOverrideAConversationResumedLater() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The TUI resume into b: the conversation is written without any + // prompt from this tab yet, so the scan follows the file time. + appendUserPrompt(dir, "b", "earlier turn", START_MS + 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 5_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the resumed conversation becomes the tracked identity"); + + // Up recalls a prompt and Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The other launcher's own human message lands in the shared file. + appendUserPrompt(dir, "a", "other tab turn", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed conversation, later. + appendUserPrompt(dir, "b", "recalled prompt", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the resumed conversation written last is the one on screen"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A tab launched as a second writer of saved conversation {@code a} + * temporarily adopts a live sibling's conversation {@code c} while its own + * record is not flushed yet. Once its matching record lands in {@code a}, + * the launch file must be eligible again: it is established by another live + * tab, so the ownership exclusion alone would keep the sibling preferred. + */ + @Test + void aSharedLaunchConversationWinsBackAfterATemporaryAdoption() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "c")); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "c")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's conversation holds the text first. + appendUserPrompt(dir, "c", "continue", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + assertEquals("c", controller.resolveActiveSessionId(), + "the sibling's conversation is adopted while no own record exists"); + + // This tab's record lands in the shared launch conversation, later. + appendUserPrompt(dir, "a", "continue", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation's record must take the identity back"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the launch conversation"); + } finally { + tabC.stopProcess(); + tabB.stopProcess(); + } + } + /** * The same race with known prompt text. Once the tab has borrowed a * sibling's conversation, the launch-known preference must not keep it From 5688bbc1a17074c625736d4b598454a5ce58c21e Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 08:46:23 +0300 Subject: [PATCH 047/111] Preserved held conversations through the final stop lookup Stopping the writer runs the process exit callback, which releases this tab's claims. The final identity lookup before Save & Switch renames then saw the tab's own conversation as another live tab's file and could pick an unrelated conversation that merely received a user record after the submission, even though the tab's own file was strictly newer. stopAndRename() now captures the session IDs the tab held before stopping the writer and resolves the final identity with that evidence preserved, so the pre-stop ownership classification is unchanged by the exit callback. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 71 +++++++++++++++++-- .../ClaudeSessionControllerTest.java | 63 ++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 78777148..87076f01 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -242,6 +242,16 @@ private static final class SessionClaims { */ private volatile String launchSessionId; + /** + * Session IDs this tab held before it stopped its writer, kept eligible for + * the final post-stop identity lookup of {@link #stopAndRename(String)}. + * Stopping the PTY runs the exit callback, which releases this tab's live + * claims; without the preserved evidence this tab's own conversation would + * then look like another live tab's file (or like a file established by + * another tab) and the lookup could pick an unrelated conversation. + */ + private volatile Set preservedClaims = Set.of(); + /** * Time (epoch millis) a prompt was last submitted from the plugin. Used to * attribute the session file created by a new process to this tab promptly, @@ -809,6 +819,7 @@ public void stopProcess() { identityPollTicks = 0; LIVE_SESSIONS.remove(this); releaseAllClaims(); + preservedClaims = Set.of(); } terminalWidget = null; modelComboPopulated = false; @@ -849,9 +860,12 @@ public void stopAndRename(String sessionName) { // 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. + // evidence rules: whatever it wrote last is on disk now. The exit + // callback releases this tab's claims, so the conversations it held + // are carried over explicitly. + Set held = claimedSessionIds(dir, configDir); stopWriter(); - sessionId = resolveActiveSessionId(); + sessionId = resolveIdentityWithPreservedClaims(held); } stopProcess(); @@ -894,6 +908,29 @@ public String resolveActiveSessionId() { return activeSessionId; } + /** + * Resolves the identity of a tab whose writer has just been stopped, while + * keeping the conversations it held before the stop eligible. + * + *

    The post-stop lookup must use the pre-stop ownership evidence: once + * the exit callback has released this tab's claims, a conversation the tab + * established would otherwise look like another live tab's file, and the + * lookup could fall back to an unrelated conversation that merely received + * a user record after the submission. + * + * @param heldIds session IDs this tab held before the writer was stopped + * @return the resolved session ID, or {@code null} + */ + String resolveIdentityWithPreservedClaims(Set heldIds) { + Set previous = preservedClaims; + preservedClaims = heldIds != null ? Set.copyOf(heldIds) : Set.of(); + try { + return resolveActiveSessionId(); + } finally { + preservedClaims = previous; + } + } + /** * Re-resolves the session the running process is writing and keeps the * cross-tab claim registry in sync: when Claude switches conversations @@ -1517,6 +1554,9 @@ private Set sessionsOwnedByOtherLiveTabs(java.io.File dir, || !key.projectDir().equals(wantedProject)) { return; } + // A file this tab held until it stopped its writer is its own, so + // the post-stop lookup must not treat it as another tab's. + if (preservedClaims.contains(key.sessionId())) return; for (ClaudeSessionController owner : claims.owners) { if (owner != this && LIVE_SESSIONS.contains(owner) && owner.isProcessLive()) { ids.add(key.sessionId()); @@ -1527,6 +1567,25 @@ private Set sessionsOwnedByOtherLiveTabs(java.io.File dir, return ids; } + /** + * Package-private for tests and the stop path: session IDs this controller + * currently holds in the given store. + */ + Set claimedSessionIds(java.io.File dir, java.nio.file.Path configDir) { + Set ids = new HashSet<>(); + if (dir == null || configDir == null) return ids; + java.nio.file.Path wantedConfig = configDir.toAbsolutePath().normalize(); + java.nio.file.Path wantedProject = dir.toPath().toAbsolutePath().normalize(); + SESSION_CLAIMS.forEach((key, claims) -> { + if (key.configDir().equals(wantedConfig) + && key.projectDir().equals(wantedProject) + && claims.holders.contains(this)) { + ids.add(key.sessionId()); + } + }); + return ids; + } + /** * Session IDs claimed by other live controllers in the same store. * A session ID is not unique across config dirs (a JSONL can be copied @@ -1545,8 +1604,12 @@ private Set claimedByOthers(java.io.File dir, java.nio.file.Path configD } // A session shared with another tab (both resumed the same saved // conversation) is still this tab's own conversation: excluding it - // would make the scan pick an unrelated file. - if (claims.holders.contains(this)) { + // would make the scan pick an unrelated file. The same holds for a + // file this tab held before it stopped its writer: the exit + // callback has already released the claim, but that must not turn + // the tab's own conversation into another tab's file. + if (claims.holders.contains(this) + || preservedClaims.contains(key.sessionId())) { return; } for (ClaudeSessionController holder : claims.holders) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 1f91edac..6e34910a 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2834,6 +2834,69 @@ void aSharedLaunchConversationWinsBackAfterATemporaryAdoption() throws Exception } } + /** + * A NEW tab has resolved its own conversation {@code a} and a sibling NEW + * tab is borrowing it. Stopping the writer runs the exit callback, which + * releases this tab's claims — the final lookup before the rename must + * still treat {@code a} as this tab's own, or it falls back to an unrelated + * conversation that merely received a user record after the submission, + * even though {@code a} is strictly newer. + */ + @Test + void theFinalStopLookupKeepsTheConversationsTheTabHeld() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("a", "noise")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + + try { + // Up recalls a prompt, Enter submits it: its text is unknown. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 11_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + appendUserPrompt(dir, "a", "recalled prompt", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the tab's own conversation is adopted"); + + // A sibling NEW tab borrows the resolved conversation. + tabB.simulateProcessStart(START_MS + 12_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // An unrelated conversation also receives a human record after the + // submission; the tab's own file stays strictly newer. + appendUserPrompt(dir, "noise", "unrelated work", START_MS + 12_100L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 12_200L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + Set held = controller.claimedSessionIds(work.toFile(), config); + assertEquals(Set.of("a"), held, "the resolved conversation is held"); + + // The waiter runs the exit callback while the writer is stopping. + controller.simulateProcessExit(); + + assertEquals("a", controller.resolveIdentityWithPreservedClaims(held), + "the final lookup must keep the conversations the tab held"); + } finally { + tabB.stopProcess(); + } + } + /** * The same race with known prompt text. Once the tab has borrowed a * sibling's conversation, the launch-known preference must not keep it From 61956731eb334538e2c3714c2637a8d080a6eabe Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 19:52:19 +0300 Subject: [PATCH 048/111] Preserved only own conversations for the final stop lookup The final lookup carried over every session the tab held, including one it merely borrowed from a live sibling while both submitted the same text. That removed the sibling's ownership from the evidence, which made the borrowed conversation preferred again and could rename it even when the tab's own file was flushed during the stop and was strictly newest. Only conversations this tab established or guessed as its own are preserved; a borrowed conversation stays another live tab's file. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 33 ++++++---- .../ClaudeSessionControllerTest.java | 60 +++++++++++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 87076f01..e4f2b6fc 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -243,12 +243,17 @@ private static final class SessionClaims { private volatile String launchSessionId; /** - * Session IDs this tab held before it stopped its writer, kept eligible for - * the final post-stop identity lookup of {@link #stopAndRename(String)}. - * Stopping the PTY runs the exit callback, which releases this tab's live - * claims; without the preserved evidence this tab's own conversation would - * then look like another live tab's file (or like a file established by - * another tab) and the lookup could pick an unrelated conversation. + * Session IDs this tab established or guessed as its own before it stopped + * its writer, kept eligible for the final post-stop identity lookup of + * {@link #stopAndRename(String)}. Stopping the PTY runs the exit callback, + * which releases this tab's live claims; without the preserved evidence + * this tab's own conversation would then look like another live tab's file + * (or like a file established by another tab) and the lookup could pick an + * unrelated conversation. + * + *

    A conversation the tab only borrowed is not listed: it belongs to + * another live tab, and preserving it would make the borrowed file win over + * the file this tab actually created. */ private volatile Set preservedClaims = Set.of(); @@ -861,8 +866,10 @@ public void stopAndRename(String sessionName) { // 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 held - // are carried over explicitly. + // 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. Set held = claimedSessionIds(dir, configDir); stopWriter(); sessionId = resolveIdentityWithPreservedClaims(held); @@ -1569,7 +1576,12 @@ private Set sessionsOwnedByOtherLiveTabs(java.io.File dir, /** * Package-private for tests and the stop path: session IDs this controller - * currently holds in the given store. + * established or guessed as its own in the given store. + * + *

    A held conversation that this controller only borrowed (another live + * tab established it or was writing it first) is another tab's file and is + * not included: preserving it as this tab's own would let it win the final + * post-stop lookup over the file this tab actually created. */ Set claimedSessionIds(java.io.File dir, java.nio.file.Path configDir) { Set ids = new HashSet<>(); @@ -1579,7 +1591,8 @@ Set claimedSessionIds(java.io.File dir, java.nio.file.Path configDir) { SESSION_CLAIMS.forEach((key, claims) -> { if (key.configDir().equals(wantedConfig) && key.projectDir().equals(wantedProject) - && claims.holders.contains(this)) { + && claims.holders.contains(this) + && !claims.borrowed.contains(this)) { ids.add(key.sessionId()); } }); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 6e34910a..07ea1812 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2897,6 +2897,66 @@ void theFinalStopLookupKeepsTheConversationsTheTabHeld() throws Exception { } } + /** + * A NEW tab temporarily borrows a live sibling's conversation because both + * submitted the same text before its own file was flushed. The borrowed + * file is the sibling's, so the final stop lookup must not preserve it as + * this tab's own: once this tab's own file is flushed during the stop, that + * file is the answer even if the sibling's conversation is currently + * tracked. + */ + @Test + void aBorrowedConversationIsNotPreservedForTheFinalStopLookup() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's conversation holds the text first. + appendUserPrompt(dir, "b", "continue", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the sibling's conversation is borrowed while no own file exists"); + + // This tab's own file is flushed during the stop, strictly newest. + appendUserPrompt(dir, "a", "continue", START_MS + 11_600L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_600L); + + Set held = controller.claimedSessionIds(work.toFile(), config); + assertEquals(Set.of(), held, + "a borrowed conversation is not this tab's own file"); + + controller.simulateProcessExit(); + + assertEquals("a", controller.resolveIdentityWithPreservedClaims(held), + "the tab's own file must win over the borrowed one"); + } finally { + tabB.stopProcess(); + } + } + /** * The same race with known prompt text. Once the tab has borrowed a * sibling's conversation, the launch-known preference must not keep it From d3db1ab78e500893a644105642693b41e82ff8d0 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 20:18:03 +0300 Subject: [PATCH 049/111] Stopped preferring borrowed guesses and limited the first-file priority to own files Two identity-lookup races could still rename a sibling's conversation. The known-text borrowed pass always preferred the tracked identity. When a launch-known tab temporarily adopted a sibling's conversation because that sibling flushed the identical prompt first, the tracked identity stayed borrowed; once the conversation the tab actually joined held the submission and was strictly newer, the stale preference still returned the sibling's file. The preference now belongs to the launch identity only. The unknown-text own-file priority was keyed only on `launchIdentityKnown`, so it kept adopting any unclaimed file for a NEW run that had already resolved its own conversation. A new `ownFileResolved` flag, set when a published scan resolves the run's own conversation and reset together with the run, now limits the priority to the conversations this tab holds. That still keeps the run's own file preferred on later scans, while an exited tab's unrelated record yields to the shared conversation the submission actually went to. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 95 ++++++++++--- .../ClaudeSessionControllerTest.java | 134 ++++++++++++++++++ 2 files changed, 211 insertions(+), 18 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index e4f2b6fc..45716d55 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -242,6 +242,17 @@ private static final class SessionClaims { */ private volatile String launchSessionId; + /** + * {@code true} once a {@link SessionMode#NEW} run has resolved the file it + * created for its own first submission. The unknown-text own-file priority + * belongs to that first file only: afterwards the run's conversation is + * established, and a later recalled prompt must be looked up among the + * shared conversations instead of adopting an unrelated unclaimed file that + * merely received a user record after the submission (see + * {@link #refreshSessionIdentity()}). + */ + private volatile boolean ownFileResolved; + /** * Session IDs this tab established or guessed as its own before it stopped * its writer, kept eligible for the final post-stop identity lookup of @@ -432,6 +443,7 @@ void simulateActiveSession(java.nio.file.Path configDir, String sessionId, preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); launchIdentityKnown = sessionId != null; launchSessionId = sessionId; + ownFileResolved = false; } /** Package-private for tests: one tick of the periodic identity refresh. */ @@ -664,6 +676,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr activeSessionId = launch.sessionId(); launchIdentityKnown = activeSessionId != null; launchSessionId = activeSessionId; + ownFileResolved = false; PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : "", @@ -817,6 +830,7 @@ public void stopProcess() { preStartSessionIds = Set.of(); launchIdentityKnown = false; launchSessionId = null; + ownFileResolved = false; promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; @@ -998,6 +1012,7 @@ boolean refreshSessionIdentity() { } } String newest = null; + boolean ownEvidence = false; if (dir != null && configDir != null && since > 0) { // A conversation owned by a live tab (or by this tab) that already // contains the prompt submitted from here is the strongest evidence @@ -1072,6 +1087,11 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, ownExclude, ownPreferred, promptAt, promptText); } + if (newest != null) { + // The own-prompt evidence found this run's conversation: + // a NEW run has created the file it writes to. + ownEvidence = true; + } if (newest == null) { // Only then may a conversation another live tab writes be // adopted: that is an in-process /resume from this terminal. @@ -1086,8 +1106,16 @@ boolean refreshSessionIdentity() { ClaudeSessionStore.listSessionIdsWrittenSince( dir.toPath(), configDir, 0L, null, borrowed, borrowed.size())); - newest = findPromptBearingSession(dir, configDir, ordered, activeId, - promptAt, promptText); + // The tracked conversation is preferred here only when it is + // the launch identity. A borrowed identity was itself + // guessed from the same prompt text, so keeping it + // preferred would pin this tab to a sibling's file even + // after the conversation it actually joined holds the + // submission and is strictly newer. + String borrowedPreferred = activeId != null + && activeId.equals(launchSessionId) ? activeId : null; + newest = findPromptBearingSession(dir, configDir, ordered, + borrowedPreferred, promptAt, promptText); } } if (promptAt > 0 && promptAt >= externalAt @@ -1097,13 +1125,17 @@ boolean refreshSessionIdentity() { // user record is what proves a file was written for this // submission. // - // Only an unresolved NEW tab gets the new-file priority: it - // writes its own file without any other tab claiming it, while - // a sibling's record submitted around the same time may just be - // that tab's own turn. A tab whose conversation was known at - // launch had nothing new to create, so an unrelated unclaimed - // human record (e.g. an exited third tab's own prompt) must not - // displace the shared conversation it switched into. + // Only a NEW tab gets this priority: it may write its first + // file without any other tab claiming it, while a sibling's + // record submitted around the same time may just be that tab's + // own turn. A tab whose conversation was known at launch had + // nothing new to create, so an unrelated unclaimed human record + // (e.g. an exited third tab's own prompt) must not displace the + // shared conversation it switched into. Once a NEW run has + // resolved its own file, the priority is limited to the + // conversations this tab holds: a later submission cannot be + // the one creating the first file, so an unrelated unclaimed + // record must yield to the shared conversation the run joined. // // Its launch conversation is different: a user record appended // to that file after the submission suggests the submission @@ -1117,11 +1149,33 @@ boolean refreshSessionIdentity() { // tab borrowed earlier is now held by this tab too, so the // claim-based exclusion no longer hides it. Only a file no // other live tab established may count as this tab's own. - newest = newestSessionWithUserMessageSince(dir, configDir, since, - promptAt, ownExclude); - if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { - newest = newestSessionWithUserMessageSince(dir, configDir, - since - SESSION_MTIME_GRANULARITY_MS, promptAt, ownExclude); + Set ownCandidates = null; + if (ownFileResolved) { + // The run has already created its own file, so a later + // unknown-text submission cannot be the one that + // creates it. Only this tab's own conversations keep + // the priority: an unrelated unclaimed file (e.g. an + // exited tab's record) must yield to the shared + // conversation the run joined. + ownCandidates = claimedSessionIds(dir, configDir); + } + if (ownCandidates == null) { + newest = newestSessionWithUserMessageSince(dir, configDir, since, + promptAt, ownExclude); + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + newest = newestSessionWithUserMessageSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, + ownExclude); + } + ownEvidence = newest != null; + } else { + newest = newestSharedSessionWrittenSince(dir, configDir, since, + promptAt, ownCandidates, exclude); + if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { + newest = newestSharedSessionWrittenSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, + ownCandidates, exclude); + } } } if (newest == null) { @@ -1175,7 +1229,8 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, relaxedExclude); } } - return publishIdentityScanResult(generation, scanId, dir, configDir, newest); + return publishIdentityScanResult(generation, scanId, dir, configDir, newest, + ownEvidence); } /** @@ -1309,7 +1364,7 @@ private static boolean containsPrompt(java.io.File dir, java.nio.file.Path confi private boolean publishIdentityScanResult(long generation, long scanId, java.io.File dir, java.nio.file.Path configDir, - String newest) { + String newest, boolean ownEvidence) { synchronized (processOwnershipLock) { if (generation != processGeneration) { return activeSessionId != null; @@ -1318,6 +1373,9 @@ private boolean publishIdentityScanResult(long generation, long scanId, return activeSessionId != null; } lastPublishedScanId = scanId; + if (ownEvidence && newest != null) { + ownFileResolved = true; + } if (newest != null && !newest.equals(activeSessionId)) { String previous = activeSessionId; activeSessionId = newest; @@ -1341,12 +1399,13 @@ long simulateBeginIdentityScan() { /** Package-private for tests: publishes a result scanned for an older process run. */ boolean simulatePublishStaleScanResult(long generation, String newest) { return publishIdentityScanResult(generation, simulateBeginIdentityScan(), - null, activeConfigDir, newest); + null, activeConfigDir, newest, false); } /** Package-private for tests: publishes a scan result with explicit ordering. */ boolean simulatePublishScanResult(long generation, long scanId, String newest) { - return publishIdentityScanResult(generation, scanId, null, activeConfigDir, newest); + return publishIdentityScanResult(generation, scanId, null, activeConfigDir, newest, + false); } /** Package-private for tests: the identity inputs {@link #refreshSessionIdentity()} captures. */ diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 07ea1812..fb7c4b15 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2957,6 +2957,140 @@ void aBorrowedConversationIsNotPreservedForTheFinalStopLookup() throws Exception } } + /** + * A launch-known tab temporarily adopted a sibling's conversation {@code c} + * because the sibling flushed the identical prompt first. Once the + * conversation the tab actually joined holds the submission and is strictly + * newer, the borrowed guess must not stay preferred: the newest-first + * candidate order has to decide, or Save & Switch renames the sibling's + * conversation. + */ + @Test + void aBorrowedGuessDoesNotPinALaunchKnownTabToTheOlderConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b", "c")); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "b", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The sibling's conversation holds the text first. + appendUserPrompt(dir, "c", "continue", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + assertEquals("c", controller.resolveActiveSessionId(), + "the identical sibling prompt is adopted while nothing else matches"); + + // The conversation this tab actually joined gets its record later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the borrowed guess must not beat the strictly newer joined file"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the joined conversation"); + } finally { + tabC.stopProcess(); + tabB.stopProcess(); + } + } + + /** + * A NEW run resolved its own conversation for the first submission. When it + * later joins a live sibling's conversation and submits a recalled prompt + * (unknown text), the first-file priority is spent: an exited tab's + * unrelated unclaimed file must not be adopted ahead of the shared + * conversation that holds the submission, even though that file is newer + * than nothing else considered. + */ + @Test + void theFirstFilePriorityEndsAfterANewRunResolvedItsOwnFile() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, null, Set.of("a", "b", "c")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // This run creates its own conversation for the first submission. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the first-file priority resolves the run's own conversation"); + + // A recalled prompt is submitted after the TUI joined b. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 21_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 21_001L)); + + // An unrelated exited tab's conversation also received a human + // record; the joined conversation gets the actual submission and + // is strictly newer. + appendUserPrompt(dir, "c", "other work", START_MS + 21_600L); + setMtime(dir.resolve("c.jsonl"), START_MS + 21_700L); + appendUserPrompt(dir, "b", "recalled prompt", START_MS + 21_900L); + setMtime(dir.resolve("b.jsonl"), START_MS + 22_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared conversation holding the submission must win"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the shared conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * The same race with known prompt text. Once the tab has borrowed a * sibling's conversation, the launch-known preference must not keep it From b1aabc328122793859260f111e1f31a5d595c3d2 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 21:06:47 +0300 Subject: [PATCH 050/111] Kept the resolved NEW session eligible and stopped recording menu digits The own-file candidate set of a resolved NEW run was derived only from the live claims. Adopting a borrowed conversation releases the claim on the run's own file, so after a later recalled submission temporarily borrowed a live sibling's conversation the set became empty, the shared pass returned the sibling, and the run's own file could not win even when it held the submission and was strictly newer. The resolved ID is now retained in `ownSessionId` and listed explicitly, independent of the live claims. A digit typed while a numbered choice menu is open selects an item immediately and is consumed by the TUI, but the input parser recorded it as prompt draft. A prompt typed after the menu was dismissed carried the selection as a prefix, so its malformed text could not match the conversation it was written to and Save & Switch could rename the previous conversation. Digits are no longer recorded while a choice menu is active. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 41 +++++--- .../ClaudeSessionControllerTest.java | 94 +++++++++++++++++++ 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 45716d55..865e310a 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -243,15 +243,18 @@ private static final class SessionClaims { private volatile String launchSessionId; /** - * {@code true} once a {@link SessionMode#NEW} run has resolved the file it - * created for its own first submission. The unknown-text own-file priority - * belongs to that first file only: afterwards the run's conversation is - * established, and a later recalled prompt must be looked up among the - * shared conversations instead of adopting an unrelated unclaimed file that - * merely received a user record after the submission (see + * Session ID a {@link SessionMode#NEW} run resolved as the file it created + * for its own submission, or {@code null} before that. The unknown-text + * own-file priority belongs to that file only: afterwards the run's + * conversation is established, and a later recalled prompt must be looked + * up among the run's own and the shared conversations instead of adopting + * an unrelated unclaimed file that merely received a user record after the + * submission. The ID is kept even when the tab adopts a borrowed + * conversation and releases its claim: the own file must stay a candidate + * for the submission that actually went there (see * {@link #refreshSessionIdentity()}). */ - private volatile boolean ownFileResolved; + private volatile String ownSessionId; /** * Session IDs this tab established or guessed as its own before it stopped @@ -443,7 +446,7 @@ void simulateActiveSession(java.nio.file.Path configDir, String sessionId, preStartSessionIds = preStartIds != null ? Set.copyOf(preStartIds) : Set.of(); launchIdentityKnown = sessionId != null; launchSessionId = sessionId; - ownFileResolved = false; + ownSessionId = null; } /** Package-private for tests: one tick of the periodic identity refresh. */ @@ -676,7 +679,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr activeSessionId = launch.sessionId(); launchIdentityKnown = activeSessionId != null; launchSessionId = activeSessionId; - ownFileResolved = false; + ownSessionId = null; PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : "", @@ -830,7 +833,7 @@ public void stopProcess() { preStartSessionIds = Set.of(); launchIdentityKnown = false; launchSessionId = null; - ownFileResolved = false; + ownSessionId = null; promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; @@ -1150,14 +1153,18 @@ boolean refreshSessionIdentity() { // claim-based exclusion no longer hides it. Only a file no // other live tab established may count as this tab's own. Set ownCandidates = null; - if (ownFileResolved) { + if (ownSessionId != null) { // The run has already created its own file, so a later // unknown-text submission cannot be the one that // creates it. Only this tab's own conversations keep // the priority: an unrelated unclaimed file (e.g. an // exited tab's record) must yield to the shared - // conversation the run joined. + // conversation the run joined. The resolved own ID is + // listed explicitly: adopting a borrowed conversation + // releases its claim, and the file must still be + // eligible for the submission that went there. ownCandidates = claimedSessionIds(dir, configDir); + ownCandidates.add(ownSessionId); } if (ownCandidates == null) { newest = newestSessionWithUserMessageSince(dir, configDir, since, @@ -1374,7 +1381,7 @@ private boolean publishIdentityScanResult(long generation, long scanId, } lastPublishedScanId = scanId; if (ownEvidence && newest != null) { - ownFileResolved = true; + ownSessionId = newest; } if (newest != null && !newest.equals(activeSessionId)) { String previous = activeSessionId; @@ -3393,7 +3400,13 @@ private String appendTerminalInput(byte[] bytes) { terminalCompletionEdited = true; } } else if (c >= 0x20) { - insertTerminalChar(c); + // 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. + if (!(model.getActiveChoiceMenu() != null && c >= '0' && c <= '9')) { + insertTerminalChar(c); + } } } return submitted; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index fb7c4b15..f36c28dd 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3023,6 +3023,78 @@ void aBorrowedGuessDoesNotPinALaunchKnownTabToTheOlderConversation() throws Exce } } + /** + * A NEW run's resolved conversation must stay eligible even after a later + * recalled submission temporarily borrowed a live sibling's file: adopting + * the sibling releases the claim on the run's own file, so the final lookup + * must keep it as a candidate rather than considering only the borrowed + * sibling. Once the run's own file holds the submission and is strictly + * newer, it is the answer. + */ + @Test + void aResolvedNewConversationStaysEligibleAfterABorrowedGuess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the first-file priority resolves the run's own conversation"); + + // A recalled prompt is submitted; the live sibling writes its own + // record first, so the run temporarily borrows the sibling's file. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 21_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 21_001L)); + + appendUserPrompt(dir, "b", "sibling turn", START_MS + 21_100L); + setMtime(dir.resolve("b.jsonl"), START_MS + 21_200L); + assertEquals("b", controller.resolveActiveSessionId(), + "the sibling's file is borrowed while the own file is not written yet"); + + // The run's own file receives the submission, strictly newer. + appendUserPrompt(dir, "a", "recalled prompt", START_MS + 21_300L); + setMtime(dir.resolve("a.jsonl"), START_MS + 21_400L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the run's own file must win once it holds the submission"); + assertEquals("a", controller.resolveActiveSessionId(), + "later scans must keep the run's own file"); + + Set held = controller.claimedSessionIds(work.toFile(), config); + controller.simulateProcessExit(); + assertEquals("a", controller.resolveIdentityWithPreservedClaims(held), + "the final lookup must keep the run's own file eligible"); + } finally { + tabB.stopProcess(); + } + } + /** * A NEW run resolved its own conversation for the first submission. When it * later joins a live sibling's conversation and submits a recalled prompt @@ -3344,6 +3416,28 @@ void aTabInAChoiceMenuDoesNotRecordAPrompt() throws Exception { } } + /** + * A digit typed while a numbered choice menu is open selects an item + * immediately and is consumed by the TUI, so it must not become draft text + * for the prompt typed after the menu is dismissed: the malformed text + * could no longer match the file the prompt was actually written to. + */ + @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 no longer shows the menu. + model.clearChoiceMenu(); + + 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"); + } + /** * Pasting before a large draft must insert the pasted text in one * operation. Inserting character by character copies the whole unchanged From 2d19ad55c8ba95bbe0565369f430424fcab3ba1d Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 21:26:08 +0300 Subject: [PATCH 051/111] Compared shared own files with other candidates and discarded cancelled picker text The retained own session of a resolved NEW run was given unconditional priority even when another live tab also wrote that file: that tab's concurrent turn appends a human record of its own, so the own-file pass returned it even after the conversation this run actually joined held the submission and was strictly newer. Own-file candidates that another live tab writes are now removed, so they compete with the other shared candidates by recency. Text typed into a picker's search box stayed in the recorded input line when the picker was cancelled with Esc in the terminal (the GUI cancel path already resets the recorder). The next prompt was then published with the search text as a prefix and could not match the conversation it was written to. A standalone Esc pressed while a choice menu is open now discards the recorded line; ordinary Esc dismissals and cursor sequences keep their behavior. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 28 ++++- .../ClaudeSessionControllerTest.java | 105 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 865e310a..301393f8 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -331,6 +331,14 @@ private static final class SessionClaims { /** 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 while a bracketed paste ({@code ESC [ 200 ~} … {@code ESC [ 201 ~}) * is being recorded: its newlines belong to the pasted prompt and must not @@ -1165,6 +1173,11 @@ boolean refreshSessionIdentity() { // eligible for the submission that went there. ownCandidates = claimedSessionIds(dir, configDir); ownCandidates.add(ownSessionId); + // A file another live tab also writes is not proof of + // this tab's submission: that tab's concurrent turn + // appends a human record of its own. It competes with + // the other shared candidates below by recency. + ownCandidates.removeAll(otherLiveClaims); } if (ownCandidates == null) { newest = newestSessionWithUserMessageSince(dir, configDir, since, @@ -1175,7 +1188,7 @@ boolean refreshSessionIdentity() { ownExclude); } ownEvidence = newest != null; - } else { + } else if (!ownCandidates.isEmpty()) { newest = newestSharedSessionWrittenSince(dir, configDir, since, promptAt, ownCandidates, exclude); if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { @@ -1485,6 +1498,7 @@ private void resetTerminalInputRecording() { terminalEscapeParams.setLength(0); terminalInputPaste = false; terminalPasteAfterCR = false; + terminalEscapeOnMenu = false; terminalLineEntered = false; terminalHistoryRecall = false; terminalCompletionEdited = false; @@ -3287,6 +3301,10 @@ private String appendTerminalInput(byte[] bytes) { 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. + terminalEscapeOnMenu = model.getActiveChoiceMenu() != null; // 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; @@ -3302,6 +3320,14 @@ private String appendTerminalInput(byte[] bytes) { 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. + terminalInputLine.setLength(0); + terminalInputCursor = 0; + terminalEscapeOnMenu = false; + } } else if (c == '[') { terminalInputEscape = 2; } else if (c == 'O') { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index f36c28dd..201cd293 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3023,6 +3023,85 @@ void aBorrowedGuessDoesNotPinALaunchKnownTabToTheOlderConversation() throws Exce } } + /** + * A resolved own file that another live tab also writes is not proof of + * this tab's submission: that tab's concurrent turn appends a user record + * of its own. It must compete with the other shared candidates by recency, + * so a conversation the run actually joined and that is strictly newer + * wins, instead of the own priority returning the shared file. + */ + @Test + void anOwnFileSharedWithAnotherWriterCompetesWithTheSharedCandidates() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of("a", "b")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + assertEquals("a", controller.resolveActiveSessionId(), + "the first-file priority resolves the run's own conversation"); + + // A second tab resumes the resolved conversation, and a third one + // writes the conversation this tab switches to. + tabB.simulateProcessStart(START_MS + 12_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + tabC.simulateProcessStart(START_MS + 12_000L, true); + tabC.simulateLiveSession(config, "b", Set.of("a", "b")); + + // A recalled prompt is submitted; the second tab's concurrent turn + // appends a human record to the shared own file, while the actual + // submission lands in b and is strictly newer. + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 21_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 21_001L)); + + appendUserPrompt(dir, "a", "other tab turn", START_MS + 21_100L); + setMtime(dir.resolve("a.jsonl"), START_MS + 21_200L); + appendUserPrompt(dir, "b", "recalled prompt", START_MS + 21_300L); + setMtime(dir.resolve("b.jsonl"), START_MS + 21_400L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared own file must not beat the strictly newer joined file"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the joined conversation"); + + Set held = controller.claimedSessionIds(work.toFile(), config); + controller.simulateProcessExit(); + assertEquals("b", controller.resolveIdentityWithPreservedClaims(held), + "the final lookup must make the same comparison"); + } finally { + tabC.stopProcess(); + tabB.stopProcess(); + } + } + /** * A NEW run's resolved conversation must stay eligible even after a later * recalled submission temporarily borrowed a live sibling's file: adopting @@ -3438,6 +3517,32 @@ void aMenuSelectionDigitIsNotRecordedAsPromptDraft() { "the menu selection 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"); + } + /** * Pasting before a large draft must insert the pasted text in one * operation. Inserting character by character copies the whole unchanged From b8622e3bd573802c3d767b29f628d2b931a843d5 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 21:48:15 +0300 Subject: [PATCH 052/111] Compared shared conversation matches by recency and scanned past rename titles A conversation another live tab also writes does not prove that this tab's submission went there: that tab's own turn can submit the same literal text. When the own-prompt pass matched such a file, it ended the search and kept the launch conversation even though a live conversation written strictly later held the same text. The match now yields to the live candidates compared by recency. The backward evidence scan stopped at the first record dated before the submission, assuming the file is in time order. A rename title is appended after a read of the file, so it can be dated before records already in it and hid the prompt above it from both evidence APIs. The scan goes past title records, and renameSession samples the clock after the read so its title is not dated before the records it follows. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 25 +++++++- .../process/ClaudeSessionStore.java | 40 +++++++++--- .../ClaudeSessionControllerTest.java | 63 +++++++++++++++++++ .../process/ClaudeSessionStoreTest.java | 27 ++++++++ 4 files changed, 146 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 301393f8..635e3921 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1098,7 +1098,30 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, ownExclude, ownPreferred, promptAt, promptText); } - if (newest != null) { + boolean ownFileMatch = newest != null; + if (newest != null && otherLiveClaims.contains(newest)) { + // A conversation another live tab also writes is not proof + // that this tab's submission went there: that tab's own turn + // can submit the same text. A live conversation holding the + // same text and written strictly later is then the one on + // screen, so let the live candidates decide by recency + // instead of ending the search here. + Set live = new HashSet<>(claimed); + live.addAll(otherLiveClaims); + if (activeId != null) live.add(activeId); + Set orderedLive = new LinkedHashSet<>( + ClaudeSessionStore.listSessionIdsWrittenSince( + dir.toPath(), configDir, 0L, null, live, live.size())); + String newerLive = findPromptBearingSession(dir, configDir, + orderedLive, null, promptAt, promptText); + if (newerLive != null && !newerLive.equals(newest)) { + newest = newerLive; + // Another live tab's conversation is not the file this + // run created for its own submission. + ownFileMatch = false; + } + } + if (ownFileMatch) { // The own-prompt evidence found this run's conversation: // a NEW run has created the file it writes to. ownEvidence = true; diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 4635158d..6e46fe97 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -333,9 +333,11 @@ private static boolean containsUserRecordSince(Path workingDir, Path claudeConfi try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(file.toFile(), "r")) { // Read backwards in blocks: the newest records are the most likely // match, and a long turn can push the prompt arbitrarily far from - // the end of the file. The scan stops as soon as a record older - // than the cutoff is reached, since records are appended in time - // order. + // the end of the file. The scan stops as soon as a conversation + // record older than the cutoff is reached, since those records are + // appended in time order. Rename titles are not: they are appended + // after a read of the file and may carry a timestamp sampled + // before records already in it, so the scan goes past them. long pos = raf.length(); // Bytes read so far that belong to a record cut by the block // boundaries, kept as separate chunks in file order: a record @@ -376,7 +378,9 @@ private static boolean containsUserRecordSince(Path workingDir, Path claudeConfi String[] lines = text.split("\n", -1); for (int i = lines.length - 1; i >= 0; i--) { if (match.test(lines[i])) return true; - if (isOlderThan(lines[i], sinceMillis)) return false; + if (!isTitleRecord(lines[i]) && isOlderThan(lines[i], sinceMillis)) { + return false; + } } } return false; @@ -395,9 +399,26 @@ private static int indexOf(byte[] bytes, int length, byte b) { } /** - * Returns whether the JSONL {@code line} is a record dated before - * {@code cutoffMillis}. Records are appended in time order, so everything - * before such a line is older still and the backward scan can stop. + * Returns whether the JSONL {@code line} is a session rename title record. + * + *

    Such a record is appended after a read of the file, so it can be dated + * before records already in it; its position does not prove that the + * records above it are older. + */ + private static boolean isTitleRecord(String line) { + if (line.isBlank() || !line.contains("custom-title")) return false; + try { + return "custom-title".equals(MAPPER.readTree(line).path("type").asText()); + } catch (Exception e) { + return false; + } + } + + /** + * Returns whether the JSONL {@code line} is a conversation record dated + * before {@code cutoffMillis}. Those records are appended in time order, so + * everything before such a line is older still and the backward scan can + * stop. */ private static boolean isOlderThan(String line, long cutoffMillis) { if (line.isBlank()) return false; @@ -745,6 +766,10 @@ public static void renameSession(Path workingDir, Path claudeConfigDir, if (!Files.exists(file)) { throw new IOException("Session file not found: " + file); } + // Read first and sample the clock afterwards: the title is appended + // last, so it must not be dated before the records it follows, or a + // reader that relies on the append order stops before them. + String existing = Files.readString(file, StandardCharsets.UTF_8); String timestamp = Instant.now().toString(); String line = MAPPER.writeValueAsString( MAPPER.createObjectNode() @@ -754,7 +779,6 @@ public static void renameSession(Path workingDir, Path claudeConfigDir, .put("timestamp", timestamp) ); // Append with newline - String existing = Files.readString(file, StandardCharsets.UTF_8); String newContent = existing.endsWith("\n") ? existing + line + "\n" : existing + "\n" + line + "\n"; Files.writeString(file, newContent, StandardCharsets.UTF_8); LOG.fine("Renamed session " + sessionId + " to '" + newName + "'"); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 201cd293..7a73d25b 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3102,6 +3102,69 @@ void anOwnFileSharedWithAnotherWriterCompetesWithTheSharedCandidates() throws Ex } } + /** + * A launch file that another live tab also writes is not proof that this + * tab's submission went there: that tab's own turn can submit the same + * literal text. A live conversation holding the same text and written + * strictly later must win instead of the launch file ending the search. + */ + @Test + void aSharedLaunchFileMatchYieldsToAStrictlyNewerLiveConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + // The conversation this tab resumes in the TUI is live as well. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 1_000L, true); + tabC.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The other launcher's own turn submits the same literal text into + // the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed conversation, later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared launch match must not beat the strictly newer live file"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabC.stopProcess(); + tabB.stopProcess(); + } + } + /** * A NEW run's resolved conversation must stay eligible even after a later * recalled submission temporarily borrowed a live sibling's file: adopting diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index dd1ab373..297f77c6 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -473,6 +473,33 @@ void containsUserPromptSince_scansAFileMadeOfOneHugeRecord() throws IOException OLD_TS.toEpochMilli(), "never present")); } + /** + * A rename appends a {@code custom-title} record whose timestamp is + * sampled before the file is read, so it can be dated before a user record + * already in the file. The backward scan must not treat that metadata + * record as the end of the evidence window and hide the prompt above it, + * for either evidence API. + */ + @Test + void containsUserPromptSince_findsThePromptAboveAnOlderTitleRecord() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + Path file = sessionDir.resolve("s1.jsonl"); + // Dated in the future so the title appended by the rename below is + // older than the prompt, as when the clock is sampled before the read. + java.time.Instant promptAt = java.time.Instant.now().plusSeconds(60); + writeJsonl(file, userLine("the prompt from this tab", promptAt)); + + ClaudeSessionStore.renameSession(workDir, tmpDir, "s1", "Renamed"); + + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + promptAt.minusSeconds(30).toEpochMilli(), "the prompt from this tab"), + "a title record dated before the prompt must not end the scan"); + assertTrue(ClaudeSessionStore.containsUserMessageSince(workDir, tmpDir, "s1", + promptAt.minusSeconds(30).toEpochMilli()), + "the unknown-text evidence must not be hidden either"); + } + @Test void containsUserPromptSince_noEvidence_returnsFalse() throws IOException { Path workDir = tmpDir.resolve("myproject"); From 28662a9ec04ec25761df6c115f32a0ff5a966f51 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 22:07:54 +0300 Subject: [PATCH 053/111] Consumed pending picker cancellations and kept prompt evidence for blank sends An Esc that cancelled a choice menu was remembered until the next write disambiguated it, but a write starting with a new escape sequence (a bracketed paste begins with one) overwrote that state first: the picker's search text then prefixed the pasted prompt. The pending cancellation is now consumed before the new sequence starts. A whitespace-only GUI send recorded itself as an unknown-text prompt, so the scan looked for a user record without text of this tab. A sibling that appended a real prompt afterwards was then adopted even though this tab's own conversation was strictly newer. Blank text now keeps the previous evidence. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 49 +++++++++-- .../ClaudeSessionControllerTest.java | 87 +++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 635e3921..574ab377 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -472,6 +472,11 @@ void simulatePromptText(String text) { lastPromptText = text; } + /** Package-private for tests: the prompt-evidence part of {@link #sendPrompt(String)}. */ + void simulateSentPrompt(String text, long sentAt) { + rememberSentPrompt(text, sentAt); + } + /** Package-private for tests: the number of identity scans started. */ long simulateScanSequence() { synchronized (processOwnershipLock) { @@ -1529,6 +1534,17 @@ private void resetTerminalInputRecording() { } } + /** + * Discards the recorded input after a standalone {@code ESC} cancelled a + * choice menu: the line then holds the picker's search text, not a prompt + * draft. Call while holding the {@link #terminalInputLine} lock. + */ + private void discardPickerSearchText() { + terminalInputLine.setLength(0); + terminalInputCursor = 0; + terminalEscapeOnMenu = false; + } + /** Package-private for tests: the PTY is no longer running (Claude exited). */ void simulateProcessNotRunning() { claudeProcess = new ClaudeProcess(); @@ -1816,9 +1832,7 @@ public void sendPrompt(String text) { if (!text.isBlank()) { model.addPromptToHistory(text); } - lastPromptText = text; - promptSubmittedAt = System.currentTimeMillis(); - scheduleIdentityRefresh(); + rememberSentPrompt(text, System.currentTimeMillis()); Thread t = new Thread(() -> { try { @@ -1835,6 +1849,24 @@ public void sendPrompt(String text) { t.start(); } + /** + * 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) { + if (!text.isBlank()) { + lastPromptText = text; + promptSubmittedAt = sentAt; + scheduleIdentityRefresh(); + } + } + /** * Sends ESC (0x1B) to the PTY and transitions to READY state. * No-op if no PTY connector is active. @@ -3321,6 +3353,13 @@ private String appendTerminalInput(byte[] bytes) { for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); 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. + discardPickerSearchText(); + } terminalInputEscape = 1; terminalEscapeParams.setLength(0); terminalPasteAfterCR = false; @@ -3347,9 +3386,7 @@ private String appendTerminalInput(byte[] bytes) { // The carried Esc was a standalone cancellation of a // choice menu: discard the picker's search text so it // cannot prefix the next prompt. - terminalInputLine.setLength(0); - terminalInputCursor = 0; - terminalEscapeOnMenu = false; + discardPickerSearchText(); } } else if (c == '[') { terminalInputEscape = 2; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7a73d25b..7c1f4dba 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3606,6 +3606,93 @@ void aPickerSearchCancelledLiveDoesNotPrefixTheNextPrompt() { "the cancelled picker search must not prefix the submitted prompt"); } + /** + * 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 From 8acd1bc09cf4898dbe2b4736e5c259bff61a7898 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 22:24:02 +0300 Subject: [PATCH 054/111] Compared shared launch matches with unclaimed destinations after terminal input When the launch conversation is also written by another live tab, its matching record may be that tab's turn, so the match is no longer conclusive once the user has typed in the terminal: the TUI may have resumed another conversation, including a saved one no live tab holds. Every file written since the submission then competes by recency. A GUI-only run cannot have switched conversations that way, so its launch conversation stays conclusive and the unrelated-file and shared-claim guarantees are unchanged. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 38 +++++++------ .../ClaudeSessionControllerTest.java | 54 +++++++++++++++++++ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 574ab377..923ff36c 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1104,25 +1104,29 @@ boolean refreshSessionIdentity() { ownPreferred, promptAt, promptText); } boolean ownFileMatch = newest != null; - if (newest != null && otherLiveClaims.contains(newest)) { + if (newest != null && externalAt > 0 && otherLiveClaims.contains(newest)) { // A conversation another live tab also writes is not proof // that this tab's submission went there: that tab's own turn - // can submit the same text. A live conversation holding the - // same text and written strictly later is then the one on - // screen, so let the live candidates decide by recency - // instead of ending the search here. - Set live = new HashSet<>(claimed); - live.addAll(otherLiveClaims); - if (activeId != null) live.add(activeId); - Set orderedLive = new LinkedHashSet<>( - ClaudeSessionStore.listSessionIdsWrittenSince( - dir.toPath(), configDir, 0L, null, live, live.size())); - String newerLive = findPromptBearingSession(dir, configDir, - orderedLive, null, promptAt, promptText); - if (newerLive != null && !newerLive.equals(newest)) { - newest = newerLive; - // Another live tab's conversation is not the file this - // run created for its own submission. + // can submit the same text. When the user has typed in the + // terminal, the TUI may have switched the conversation, and + // a file holding the same text and written strictly later + // — including one no live tab holds yet, e.g. a saved + // conversation resumed with the picker — is then the one on + // screen. Every file written since the submission competes + // by recency instead of the shared match ending the search. + // A GUI-only run cannot have switched conversations that + // way, so its launch conversation stays conclusive. + String newer = findPromptBearingSession(dir, configDir, since, + Set.of(), null, promptAt, promptText); + if (newer == null && since > SESSION_MTIME_GRANULARITY_MS) { + newer = findPromptBearingSession(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, Set.of(), null, + promptAt, promptText); + } + if (newer != null && !newer.equals(newest)) { + newest = newer; + // A conversation written later by someone else is not + // the file this run created for its own submission. ownFileMatch = false; } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7c1f4dba..864afe80 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3102,6 +3102,60 @@ void anOwnFileSharedWithAnotherWriterCompetesWithTheSharedCandidates() throws Ex } } + /** + * The destination of a shared launch-file match may be a saved conversation + * the user resumed in the TUI that no live tab holds yet: a record written + * strictly later with the same text is then the conversation on screen. + */ + @Test + void aSharedLaunchFileMatchYieldsToAStrictlyNewerUnclaimedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + controller.simulateTerminalInput( + "continue".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 11_000L); + assertEquals("continue", controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_001L)); + + // The other launcher's own turn submits the same literal text into + // the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared launch match must not beat the strictly newer file"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * A launch file that another live tab also writes is not proof that this * tab's submission went there: that tab's own turn can submit the same From 0ca28b4e3909b8cfbcc482e06e58bfe7778f459c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 22:38:30 +0300 Subject: [PATCH 055/111] Kept prompt evidence when Enter is pressed on an empty terminal line The Enter handler advanced the external-input time for every Enter, including one that closed a still-empty line with no choice menu open. That Enter acts on nothing in the TUI, but the newer time made the submitted prompt's evidence fail its freshness check, and the file-time fallbacks exclude the resumed conversation's live claim: Save & Switch then renamed the previous conversation. Such a no-op Enter no longer advances the external-input time. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 20 ++++++- .../ClaudeSessionControllerTest.java | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 923ff36c..a9b64879 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1532,6 +1532,7 @@ private void resetTerminalInputRecording() { terminalPasteAfterCR = false; terminalEscapeOnMenu = false; terminalLineEntered = false; + terminalEnterOnEmptyLine = false; terminalHistoryRecall = false; terminalCompletionEdited = false; terminalRecalledSubmission = false; @@ -3307,16 +3308,20 @@ private void writePty(String text) throws IOException { private String onTerminalInput(byte[] bytes, long now) { String submitted; boolean entered; + boolean emptyEnter; boolean recalled; synchronized (terminalInputLine) { terminalLineEntered = false; + terminalEnterOnEmptyLine = false; submitted = appendTerminalInput(bytes); entered = terminalLineEntered; terminalLineEntered = false; + emptyEnter = terminalEnterOnEmptyLine; + terminalEnterOnEmptyLine = false; recalled = terminalRecalledSubmission; terminalRecalledSubmission = false; } - if (entered) { + if (entered && !emptyEnter) { externalInputAt = now; } if (submitted != null) { @@ -3460,6 +3465,12 @@ private String appendTerminalInput(byte[] bytes) { 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 + && model.getActiveChoiceMenu() == null; if (unknownText) { // The line was filled from the TUI history (Up/Down) or // completed by Tab and may have been edited since: the @@ -3505,6 +3516,13 @@ private String appendTerminalInput(byte[] bytes) { /** 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()); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 864afe80..661cc2df 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3660,6 +3660,59 @@ void aPickerSearchCancelledLiveDoesNotPrefixTheNextPrompt() { "the cancelled picker search must not prefix the submitted prompt"); } + /** + * 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(); + } + } + /** * 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 From 65ada45a05fda71e4f99e5add382eb977d6d36a3 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 23:00:15 +0300 Subject: [PATCH 056/111] Tracked GUI-driven conversation switches in the identity scan The recency recheck of a shared launch-file match ran only after raw terminal input, but the conversation can also be switched from the GUI: /resume or /clear sent from the prompt panel and the answer to the session picker are plugin writes, so the terminal-input time stayed zero. A prompt sent right after such a switch then kept the shared launch conversation, and Save & Switch renamed it instead of the conversation on screen. Such GUI switches are now recorded and enable the same recheck. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 60 ++++++++++++++++++- .../ClaudeSessionControllerTest.java | 60 +++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index a9b64879..8f8a08e3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -303,6 +303,16 @@ private static final class SessionClaims { */ private volatile long externalInputAt; + /** + * Time (epoch millis) of the last conversation change driven from the + * GUI, e.g. {@code /resume} or {@code /clear} sent from the prompt panel + * or the answer to the {@code /resume} picker. Those writes are made by the + * plugin, so they do not count as terminal input, but they still switch the + * conversation the process writes: prompt evidence captured before them no + * longer proves the shared launch conversation is the one on screen. + */ + private volatile long guiConversationSwitchAt; + /** * 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 @@ -477,6 +487,11 @@ void simulateSentPrompt(String text, long sentAt) { rememberSentPrompt(text, sentAt); } + /** Package-private for tests: the choice-menu bookkeeping of {@link #writePtyAnswer(String)}. */ + void simulateGuiChoiceAnswer(String answer) { + rememberChoiceMenuAnswer(answer); + } + /** Package-private for tests: the number of identity scans started. */ long simulateScanSequence() { synchronized (processOwnershipLock) { @@ -850,6 +865,7 @@ public void stopProcess() { promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; + guiConversationSwitchAt = 0; resetTerminalInputRecording(); identityPollTicks = 0; LIVE_SESSIONS.remove(this); @@ -995,6 +1011,7 @@ boolean refreshSessionIdentity() { long since; long promptAt; long externalAt; + long guiSwitchAt; String promptText; String activeId; Set exclude = new HashSet<>(); @@ -1009,6 +1026,7 @@ boolean refreshSessionIdentity() { since = Math.max(processStartedAt, promptSubmittedAt); promptAt = promptSubmittedAt; externalAt = externalInputAt; + guiSwitchAt = guiConversationSwitchAt; promptText = lastPromptText; activeId = activeSessionId; if (dir != null && configDir != null && since > 0) { @@ -1104,7 +1122,8 @@ boolean refreshSessionIdentity() { ownPreferred, promptAt, promptText); } boolean ownFileMatch = newest != null; - if (newest != null && externalAt > 0 && otherLiveClaims.contains(newest)) { + if (newest != null && (externalAt > 0 || guiSwitchAt > 0) + && otherLiveClaims.contains(newest)) { // A conversation another live tab also writes is not proof // that this tab's submission went there: that tab's own turn // can submit the same text. When the user has typed in the @@ -1114,8 +1133,10 @@ boolean refreshSessionIdentity() { // conversation resumed with the picker — is then the one on // screen. Every file written since the submission competes // by recency instead of the shared match ending the search. - // A GUI-only run cannot have switched conversations that - // way, so its launch conversation stays conclusive. + // A switch made from the GUI (/resume or /clear sent from + // the prompt panel, or the picker answer) leaves the + // terminal-input time at zero but is such a switch as well. + // Without either, the launch conversation stays conclusive. String newer = findPromptBearingSession(dir, configDir, since, Set.of(), null, promptAt, promptText); if (newer == null && since > SESSION_MTIME_GRANULARITY_MS) { @@ -1865,6 +1886,13 @@ public void sendPrompt(String text) { * kept instead. */ private void rememberSentPrompt(String text, long sentAt) { + if (isConversationCommand(text)) { + // 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. + guiConversationSwitchAt = sentAt; + } if (!text.isBlank()) { lastPromptText = text; promptSubmittedAt = sentAt; @@ -1872,6 +1900,31 @@ private void rememberSentPrompt(String text, long sentAt) { } } + /** + * 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"); + } + + /** + * Records that the GUI answered a choice menu. An {@code ARROW:n} answer + * selects an entry in an unnumbered picker (e.g. the {@code /resume} + * session list), which changes the conversation the process writes; 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:")) { + guiConversationSwitchAt = System.currentTimeMillis(); + } + } + /** * Sends ESC (0x1B) to the PTY and transitions to READY state. * No-op if no PTY connector is active. @@ -1919,6 +1972,7 @@ 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. resetTerminalInputRecording(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 661cc2df..fc55e773 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3156,6 +3156,66 @@ void aSharedLaunchFileMatchYieldsToAStrictlyNewerUnclaimedConversation() throws } } + /** + * The conversation can also be switched from the GUI alone — a slash + * command such as {@code /resume} sent from the prompt panel and the + * picker answer — and those writes are made by the plugin, so no raw + * terminal input marks the switch. A shared launch-file match must then + * yield to the strictly newer conversation exactly as it does after + * typing in the terminal. + */ + @Test + void aSharedLaunchFileMatchYieldsToAGuiDrivenSwitch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user switches the conversation from the GUI: /resume from + // the prompt panel, then the session picked from the menu. + controller.simulateSentPrompt("/resume", START_MS + 10_000L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // The new conversation is not followed by the identity poll yet + // when the next prompt is sent. + controller.simulateSentPrompt("continue", START_MS + 11_000L); + + // The other launcher's own turn submits the same literal text into + // the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, later. + appendUserPrompt(dir, "b", "continue", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "a GUI-driven switch must not leave the launch conversation"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * A launch file that another live tab also writes is not proof that this * tab's submission went there: that tab's own turn can submit the same From c8b61adfdae5be6b1177c77c4a0a1df09ed296d1 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 23:25:08 +0300 Subject: [PATCH 057/111] Preferred the resolved own conversation over borrowed provisional files A provisional file of another NEW tab could match the identical prompt and be borrowed; the own-prompt preference then kept returning that sibling file even after the tab's own file held the prompt and was strictly newer, and a borrowed match was recorded as the run's own file. A match in a file this tab only follows is no longer own evidence, and the file this run resolved is preferred unless another live tab established it or this tab merely follows it. A launch-known tab that had switched to another conversation and resolved it was also unable to recover it after a recalled prompt: the unknown-text search only ran for NEW runs, so the sibling file it temporarily followed was kept and renamed. The retained own ID is now eligible for launch-known runs as well. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 66 +++++++-- .../ClaudeSessionControllerTest.java | 130 ++++++++++++++++++ 2 files changed, 182 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 8f8a08e3..28d69775 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1105,10 +1105,24 @@ boolean refreshSessionIdentity() { // tabs submitted identical text) is the sibling's, and keeping // it preferred would pin this tab to it even after its own // launch conversation holds the prompt and is strictly newer. - String ownPreferred = activeId != null + // + // The file this run resolved for its own submission is + // preferred over whichever identity is currently tracked: a + // borrowed sibling file that matched the identical prompt + // before this tab's record was flushed must not keep winning + // once this tab's own file holds the prompt. Only a file no + // other live tab established and this tab does not merely + // follow counts: an explicit resume of the same file by + // another tab owns it. + String ownPreferred = null; + if (ownSessionId != null && !otherOwned.contains(ownSessionId) + && !isBorrowedByThisTab(dir, configDir, ownSessionId)) { + ownPreferred = ownSessionId; + } else if (activeId != null && (activeId.equals(launchSessionId) - || !otherOwned.contains(activeId)) - ? activeId : null; + || !otherOwned.contains(activeId))) { + ownPreferred = activeId; + } newest = findPromptBearingSession(dir, configDir, since, ownExclude, ownPreferred, promptAt, promptText); if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { @@ -1121,7 +1135,11 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, ownExclude, ownPreferred, promptAt, promptText); } - boolean ownFileMatch = newest != null; + // A match in a file this tab only follows is not proof that + // this run created it: it is another live tab's conversation, + // matched because both tabs submitted the same text. + boolean ownFileMatch = newest != null + && !isBorrowedByThisTab(dir, configDir, newest); if (newest != null && (externalAt > 0 || guiSwitchAt > 0) && otherLiveClaims.contains(newest)) { // A conversation another live tab also writes is not proof @@ -1189,17 +1207,24 @@ boolean refreshSessionIdentity() { // user record is what proves a file was written for this // submission. // - // Only a NEW tab gets this priority: it may write its first - // file without any other tab claiming it, while a sibling's - // record submitted around the same time may just be that tab's - // own turn. A tab whose conversation was known at launch had + // The unclaimed-record priority belongs to a run that has not + // created a file of its own yet: it may write its first file + // without any other tab claiming it, while a sibling's record + // submitted around the same time may just be that tab's own + // turn. A tab whose conversation was known at launch had // nothing new to create, so an unrelated unclaimed human record // (e.g. an exited third tab's own prompt) must not displace the - // shared conversation it switched into. Once a NEW run has - // resolved its own file, the priority is limited to the - // conversations this tab holds: a later submission cannot be - // the one creating the first file, so an unrelated unclaimed - // record must yield to the shared conversation the run joined. + // shared conversation it switched into. Once a run has resolved + // its own file, the priority is limited to the conversations + // this tab holds: a later submission cannot be the one creating + // the first file, so an unrelated unclaimed record must yield + // to the shared conversation the run joined. + // + // A launch-known run is treated the same way once it has + // switched to another conversation and resolved it: a later + // recalled submission must keep that conversation eligible, or + // the sibling file it temporarily follows is renamed in its + // place. // // Its launch conversation is different: a user record appended // to that file after the submission suggests the submission @@ -1208,7 +1233,7 @@ boolean refreshSessionIdentity() { // tab launched the same saved file, its own turn also appends a // human record there, and a TUI resume into a newer conversation // must still win. - if (newest == null && !launchIdentityKnown) { + if (newest == null && (!launchIdentityKnown || ownSessionId != null)) { // A record is not proof of ownership: a sibling's file the // tab borrowed earlier is now held by this tab too, so the // claim-based exclusion no longer hides it. Only a file no @@ -1576,6 +1601,19 @@ 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); + } + private static SessionKey keyFor(java.io.File dir, java.nio.file.Path configDir, String sessionId) { if (dir == null || configDir == null || sessionId == null) return null; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index fc55e773..f5fe4128 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4238,6 +4238,136 @@ void aSiblingBorrowingAProvisionalClaimDoesNotDisplaceIt() throws Exception { } } + /** + * 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); + + // 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); + 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 From 1971c0602254bd84f3254c8ac198dd34817b5c22 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 6 Nov 2026 23:47:35 +0300 Subject: [PATCH 058/111] Reconciled the terminal draft with GUI sends and followed GUI switches for unknown prompts The prompt panel writes its text at the terminal's cursor, so a draft typed there is part of the submitted line. sendPrompt() now records that composed line as the prompt evidence and then forgets the terminal recording: the submission consumed the draft, and it must not prefix the next line either. When a prompt's text is unknown (a history recall), a conversation switched from the GUI may be a saved one no live tab holds, which the shared pass cannot see, while the shared file's matching record can come from a sibling that submitted the same recalled text. A GUI-recorded switch therefore also accepts a record from a conversation nobody holds. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 73 ++++++++++++++--- .../ClaudeSessionControllerTest.java | 78 ++++++++++++++++++- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 28d69775..cf0607bc 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -492,6 +492,16 @@ void simulateGuiChoiceAnswer(String answer) { rememberChoiceMenuAnswer(answer); } + /** + * Package-private for tests: the recorder and evidence part of + * {@link #sendPrompt(String)}. + * + * @return the line the conversation receives + */ + String simulateGuiSend(String text, long sentAt) { + return reconcileGuiSend(text, sentAt); + } + /** Package-private for tests: the number of identity scans started. */ long simulateScanSequence() { synchronized (processOwnershipLock) { @@ -1305,6 +1315,24 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); } } + if (guiSwitchAt > 0) { + // The conversation was switched from the GUI (/resume or + // the session picker), and the destination may be a saved + // conversation no live tab holds — the shared pass above + // cannot see it. A matching record in a shared file is not + // conclusive either: a sibling that submitted the same + // recalled text writes one too. A record in a conversation + // nobody holds can only have come from this process. + String switched = newestSessionWithUserMessageSince(dir, configDir, + since, promptAt, Set.of()); + if (switched == null && since > SESSION_MTIME_GRANULARITY_MS) { + switched = newestSessionWithUserMessageSince(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, promptAt, Set.of()); + } + if (switched != null) { + newest = switched; + } + } } if (newest == null) { newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( @@ -1896,11 +1924,11 @@ public void sendPrompt(String text) { if (!text.isBlank()) { model.addPromptToHistory(text); } - rememberSentPrompt(text, System.currentTimeMillis()); + String submitted = reconcileGuiSend(text, System.currentTimeMillis()); Thread t = new Thread(() -> { try { - writePty(text); + writePty(submitted); Thread.sleep(200); writePty("\r"); } catch (IOException ex) { @@ -1913,6 +1941,40 @@ public void sendPrompt(String text) { t.start(); } + /** + * 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. That draft is + * recorded as part of the evidence and then forgotten: the panel's + * submission consumed the terminal line, 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; + synchronized (terminalInputLine) { + String draft = terminalInputLine.toString(); + int cursor = Math.min(terminalInputCursor, draft.length()); + submitted = draft.substring(0, cursor) + text + draft.substring(cursor); + } + resetTerminalInputRecording(); + if (isConversationCommand(text)) { + // 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 panel text is checked, not the composed line: + // a draft must not hide the command. + guiConversationSwitchAt = sentAt; + } + rememberSentPrompt(submitted, sentAt); + return submitted; + } + /** * Records a prompt sent from the plugin as the evidence the identity scan * matches: its text and the submission time. @@ -1924,13 +1986,6 @@ public void sendPrompt(String text) { * kept instead. */ private void rememberSentPrompt(String text, long sentAt) { - if (isConversationCommand(text)) { - // 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. - guiConversationSwitchAt = sentAt; - } if (!text.isBlank()) { lastPromptText = text; promptSubmittedAt = sentAt; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index f5fe4128..8d8abdd7 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3191,7 +3191,7 @@ void aSharedLaunchFileMatchYieldsToAGuiDrivenSwitch() throws Exception { try { // The user switches the conversation from the GUI: /resume from // the prompt panel, then the session picked from the menu. - controller.simulateSentPrompt("/resume", START_MS + 10_000L); + controller.simulateGuiSend("/resume", START_MS + 10_000L); controller.simulateGuiChoiceAnswer("ARROW:1"); // The new conversation is not followed by the identity poll yet @@ -3216,6 +3216,64 @@ void aSharedLaunchFileMatchYieldsToAGuiDrivenSwitch() throws Exception { } } + /** + * A conversation switch made from the GUI can land in a saved conversation + * no live tab holds. A recalled prompt leaves only user records as + * evidence, and a concurrent turn of the writer remaining on the shared + * launch file must not end the search: the strictly newer destination + * holds this tab's submission. + */ + @Test + void anUnknownPromptFollowsAGuiSwitchIntoAnUnclaimedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user switches the conversation from the GUI: /resume from + // the prompt panel, then the session picked from the menu. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // A recalled prompt: its text is unknown. + controller.simulatePromptSubmittedAt(START_MS + 11_000L); + controller.simulatePromptText(null); + + // The other launcher's own turn lands in the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, strictly later. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the shared launch record must not hide the unclaimed destination"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * A launch file that another live tab also writes is not proof that this * tab's submission went there: that tab's own turn can submit the same @@ -3773,6 +3831,24 @@ void anEmptyTerminalEnterKeepsTheSubmittedPromptEvidence() throws Exception { } } + /** + * 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"); + 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 From 9f7b10c26ccbe4bdadfbd596b575c48200bb881b Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 7 Nov 2026 07:39:06 +0300 Subject: [PATCH 059/111] Wrote only the panel text and kept a resolved own conversation after a GUI switch The terminal's draft is still in the TUI's input line, so it is already part of the line the Enter submits; sending the composed line as well duplicated it. The composed line stays the prompt evidence only. The unclaimed-destination search that follows a GUI switch now skips the files other live tabs hold and leaves a conversation the preceding pass already established as this run's own, so a sibling that merely appends a later human record no longer displaces it. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 27 ++++++-- .../ClaudeSessionControllerTest.java | 62 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index cf0607bc..cbccacd1 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1243,6 +1243,7 @@ boolean refreshSessionIdentity() { // tab launched the same saved file, its own turn also appends a // human record there, and a TUI resume into a newer conversation // must still win. + boolean ownRecordMatch = false; if (newest == null && (!launchIdentityKnown || ownSessionId != null)) { // A record is not proof of ownership: a sibling's file the // tab borrowed earlier is now held by this tab too, so the @@ -1276,6 +1277,7 @@ boolean refreshSessionIdentity() { ownExclude); } ownEvidence = newest != null; + ownRecordMatch = newest != null; } else if (!ownCandidates.isEmpty()) { newest = newestSharedSessionWrittenSince(dir, configDir, since, promptAt, ownCandidates, exclude); @@ -1284,6 +1286,7 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, promptAt, ownCandidates, exclude); } + ownRecordMatch = newest != null; } } if (newest == null) { @@ -1315,19 +1318,27 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); } } - if (guiSwitchAt > 0) { + if (guiSwitchAt > 0 && !ownRecordMatch) { // The conversation was switched from the GUI (/resume or // the session picker), and the destination may be a saved // conversation no live tab holds — the shared pass above // cannot see it. A matching record in a shared file is not // conclusive either: a sibling that submitted the same // recalled text writes one too. A record in a conversation - // nobody holds can only have come from this process. + // nobody holds can only have come from this process, so + // the files another live tab holds are left out. + // + // A conversation this pass already established as this + // run's own keeps the submission: the switch was followed, + // and a sibling that merely appends a later record must + // not take the tab over again. + Set unheld = new HashSet<>(exclude); + unheld.addAll(otherLiveClaims); String switched = newestSessionWithUserMessageSince(dir, configDir, - since, promptAt, Set.of()); + since, promptAt, unheld); if (switched == null && since > SESSION_MTIME_GRANULARITY_MS) { switched = newestSessionWithUserMessageSince(dir, configDir, - since - SESSION_MTIME_GRANULARITY_MS, promptAt, Set.of()); + since - SESSION_MTIME_GRANULARITY_MS, promptAt, unheld); } if (switched != null) { newest = switched; @@ -1924,11 +1935,15 @@ public void sendPrompt(String text) { if (!text.isBlank()) { model.addPromptToHistory(text); } - String submitted = reconcileGuiSend(text, System.currentTimeMillis()); + reconcileGuiSend(text, System.currentTimeMillis()); Thread t = new Thread(() -> { try { - writePty(submitted); + // 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. + writePty(text); Thread.sleep(200); writePty("\r"); } catch (IOException ex) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 8d8abdd7..c3295e83 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3274,6 +3274,68 @@ void anUnknownPromptFollowsAGuiSwitchIntoAnUnclaimedConversation() throws Except } } + /** + * A conversation this tab resolved after a GUI switch stays selected. The + * switch marker lasts until the run ends, so a later recalled prompt in + * that own conversation must not be replaced by a live sibling that merely + * appended a newer human record. + */ + @Test + void aResolvedOwnConversationSurvivesALaterGuiSwitchSearch() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + // The live sibling on c. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS, true); + tabC.simulateLiveSession(config, "c", Set.of("a", "b", "c")); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + try { + // The user switches the conversation from the GUI. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // A literal prompt resolves the unclaimed destination b as this + // tab's own conversation. + controller.simulateSentPrompt("continue", START_MS + 11_000L); + appendUserPrompt(dir, "b", "continue", START_MS + 11_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_500L); + assertEquals("b", controller.resolveActiveSessionId()); + + // A recalled prompt: its text is unknown. The own conversation + // receives the submission, but the sibling appends a newer record. + controller.simulatePromptSubmittedAt(START_MS + 12_000L); + controller.simulatePromptText(null); + appendUserPrompt(dir, "b", "recalled", START_MS + 12_400L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_500L); + appendUserPrompt(dir, "c", "sibling", START_MS + 12_600L); + setMtime(dir.resolve("c.jsonl"), START_MS + 13_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the resolved own conversation must beat a sibling's later record"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the own conversation"); + } finally { + tabC.stopProcess(); + } + } + /** * A launch file that another live tab also writes is not proof that this * tab's submission went there: that tab's own turn can submit the same From 9137b756143bc39158ae7bd021a94aa488257728 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 7 Nov 2026 08:27:12 +0300 Subject: [PATCH 060/111] Followed terminal resume commands and refused fabricated prompt evidence A /resume or /clear typed in the terminal switches the conversation just like the one sent from the prompt panel: it is recorded as a switch, not as prompt text, so the unknown-text scan may adopt a saved conversation no live tab holds. A draft filled by a history recall or a Tab completion holds only a part of the line, so a GUI submission carrying it is recorded as unknown text instead of an exact prompt the conversation never received. The unclaimed destination is compared with the shared match by file time: a file left behind by a tab that submitted its own prompt and exited must not displace the conversation on screen merely for being unheld. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 121 ++++++++++++---- .../process/ClaudeSessionStore.java | 22 +++ .../ClaudeSessionControllerTest.java | 137 ++++++++++++++++++ 3 files changed, 250 insertions(+), 30 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index cbccacd1..14fe6cb4 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -304,14 +304,15 @@ private static final class SessionClaims { private volatile long externalInputAt; /** - * Time (epoch millis) of the last conversation change driven from the - * GUI, e.g. {@code /resume} or {@code /clear} sent from the prompt panel - * or the answer to the {@code /resume} picker. Those writes are made by the - * plugin, so they do not count as terminal input, but they still switch the - * conversation the process writes: prompt evidence captured before them no - * longer proves the shared launch conversation is the one on screen. + * Time (epoch millis) of the last conversation change driven by a + * {@code /resume} or {@code /clear} command or by the answer to the + * {@code /resume} picker, whether sent from the prompt panel or typed in + * the terminal. Such a command is not a prompt written to the conversation: + * it switches the conversation the process writes, so prompt evidence + * captured before it no longer proves the shared launch conversation is the + * one on screen. */ - private volatile long guiConversationSwitchAt; + private volatile long conversationSwitchAt; /** * Line being typed in the terminal, assembled from the raw PTY input so @@ -482,6 +483,16 @@ void simulatePromptText(String text) { lastPromptText = text; } + /** Package-private for tests: the recorded text of the last submission. */ + String simulateLastPromptText() { + return lastPromptText; + } + + /** Package-private for tests: the recorded time of the last submission. */ + long simulatePromptSubmittedAtValue() { + return promptSubmittedAt; + } + /** Package-private for tests: the prompt-evidence part of {@link #sendPrompt(String)}. */ void simulateSentPrompt(String text, long sentAt) { rememberSentPrompt(text, sentAt); @@ -875,7 +886,7 @@ public void stopProcess() { promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; - guiConversationSwitchAt = 0; + conversationSwitchAt = 0; resetTerminalInputRecording(); identityPollTicks = 0; LIVE_SESSIONS.remove(this); @@ -1021,7 +1032,7 @@ boolean refreshSessionIdentity() { long since; long promptAt; long externalAt; - long guiSwitchAt; + long switchAt; String promptText; String activeId; Set exclude = new HashSet<>(); @@ -1036,7 +1047,7 @@ boolean refreshSessionIdentity() { since = Math.max(processStartedAt, promptSubmittedAt); promptAt = promptSubmittedAt; externalAt = externalInputAt; - guiSwitchAt = guiConversationSwitchAt; + switchAt = conversationSwitchAt; promptText = lastPromptText; activeId = activeSessionId; if (dir != null && configDir != null && since > 0) { @@ -1150,7 +1161,7 @@ boolean refreshSessionIdentity() { // matched because both tabs submitted the same text. boolean ownFileMatch = newest != null && !isBorrowedByThisTab(dir, configDir, newest); - if (newest != null && (externalAt > 0 || guiSwitchAt > 0) + if (newest != null && (externalAt > 0 || switchAt > 0) && otherLiveClaims.contains(newest)) { // A conversation another live tab also writes is not proof // that this tab's submission went there: that tab's own turn @@ -1161,9 +1172,9 @@ boolean refreshSessionIdentity() { // conversation resumed with the picker — is then the one on // screen. Every file written since the submission competes // by recency instead of the shared match ending the search. - // A switch made from the GUI (/resume or /clear sent from - // the prompt panel, or the picker answer) leaves the - // terminal-input time at zero but is such a switch as well. + // A switch made with a resume command — sent from the + // prompt panel, typed in the terminal, or answered in the + // picker — is such a switch as well. // Without either, the launch conversation stays conclusive. String newer = findPromptBearingSession(dir, configDir, since, Set.of(), null, promptAt, promptText); @@ -1318,15 +1329,17 @@ boolean refreshSessionIdentity() { since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); } } - if (guiSwitchAt > 0 && !ownRecordMatch) { - // The conversation was switched from the GUI (/resume or - // the session picker), and the destination may be a saved - // conversation no live tab holds — the shared pass above - // cannot see it. A matching record in a shared file is not - // conclusive either: a sibling that submitted the same - // recalled text writes one too. A record in a conversation - // nobody holds can only have come from this process, so - // the files another live tab holds are left out. + if (switchAt > 0 && !ownRecordMatch) { + // The conversation was switched with a resume command — + // sent from the prompt panel, typed in the terminal or + // answered in the picker — and the destination may be a + // saved conversation no live tab holds, which the shared + // pass above cannot see. A matching record in a shared + // file is not conclusive either: a sibling that submitted + // the same recalled text writes one too. A record in a + // conversation nobody holds can only have come from this + // process, so the files another live tab holds are left + // out. // // A conversation this pass already established as this // run's own keeps the submission: the switch was followed, @@ -1340,7 +1353,16 @@ boolean refreshSessionIdentity() { switched = newestSessionWithUserMessageSince(dir, configDir, since - SESSION_MTIME_GRANULARITY_MS, promptAt, unheld); } - if (switched != null) { + // Unheld status alone does not prove this process wrote the + // file: a tab that submitted its own prompt and then + // exited leaves one behind. Only a file written strictly + // later than the shared match is the conversation on + // screen; otherwise the shared match stands. + if (switched != null && (newest == null + || ClaudeSessionStore.sessionFileModified(dir.toPath(), + configDir, switched) + > ClaudeSessionStore.sessionFileModified(dir.toPath(), + configDir, newest))) { newest = switched; } } @@ -1972,10 +1994,16 @@ public void sendPrompt(String text) { */ private String reconcileGuiSend(String text, long sentAt) { String submitted; + boolean isTextExact; synchronized (terminalInputLine) { 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; } resetTerminalInputRecording(); if (isConversationCommand(text)) { @@ -1984,9 +2012,9 @@ private String reconcileGuiSend(String text, long sentAt) { // writes are made by the plugin, so no terminal input marks // the switch. The panel text is checked, not the composed line: // a draft must not hide the command. - guiConversationSwitchAt = sentAt; + conversationSwitchAt = sentAt; } - rememberSentPrompt(submitted, sentAt); + rememberSentPrompt(submitted, sentAt, isTextExact); return submitted; } @@ -2001,7 +2029,29 @@ private String reconcileGuiSend(String text, long sentAt) { * kept instead. */ private void rememberSentPrompt(String text, long sentAt) { - if (!text.isBlank()) { + 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(); @@ -2029,7 +2079,7 @@ private static boolean isConversationCommand(String text) { */ void rememberChoiceMenuAnswer(String answer) { if (answer != null && answer.startsWith("ARROW:")) { - guiConversationSwitchAt = System.currentTimeMillis(); + conversationSwitchAt = System.currentTimeMillis(); } } @@ -3461,7 +3511,8 @@ private void writePty(String text) throws IOException { * 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. + * be followed. A resume command typed there is the other exception: it + * switches the conversation instead of writing to it. * * @param bytes raw input bytes * @param now time of the input (epoch millis) @@ -3487,7 +3538,17 @@ private String onTerminalInput(byte[] bytes, long now) { externalInputAt = now; } if (submitted != null) { - lastPromptText = submitted; + 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. + conversationSwitchAt = now; + lastPromptText = null; + } else { + lastPromptText = submitted; + } promptSubmittedAt = now; scheduleIdentityRefresh(); } else if (recalled) { diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 6e46fe97..4518a365 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -277,6 +277,28 @@ public static List listSessionIdsWrittenSince(Path workingDir, Path clau return ids; } + /** + * Returns when the session's conversation file was last written. + * + * @param workingDir the project directory the session belongs to + * @param claudeConfigDir the Claude configuration directory + * @param sessionId the session ID + * @return the last-modified time in epoch millis, or {@code 0} when the file + * does not exist or cannot be read + */ + public static long sessionFileModified(Path workingDir, Path claudeConfigDir, + String sessionId) { + Path file = sessionsDir(workingDir, claudeConfigDir) + .resolve(sessionId + ".jsonl"); + long modified = 0L; + try { + modified = Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + LOG.fine("Could not read the time of " + file + ": " + e.getMessage()); + } + return modified; + } + /** * Returns whether the session file contains a user message with * {@code prompt} whose timestamp is at or after {@code sinceMillis}. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index c3295e83..92db614c 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3274,6 +3274,143 @@ void anUnknownPromptFollowsAGuiSwitchIntoAnUnclaimedConversation() throws Except } } + /** + * A Tab-completed or recalled draft holds only a part of the submitted + * line, so a GUI submission carrying it is recorded as unknown text + * instead of an exact prompt the conversation never received. + */ + @Test + void aGuiSendWithACompletedDraftRecordsUnknownText() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + controller.simulateTerminalInput( + "@fi".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 1_000L); + + assertEquals("@fi hello", controller.simulateGuiSend(" hello", START_MS + 2_000L), + "the composed line is still what the conversation receives"); + assertNull(controller.simulateLastPromptText(), + "a completed draft makes the submitted text unknown"); + assertEquals(START_MS + 2_000L, controller.simulatePromptSubmittedAtValue(), + "the submission time is still recorded"); + } + + /** + * A switch driven from the terminal (a resume picked with the keyboard) + * must be followed for an unknown prompt as well: the shared launch file's + * concurrent record must not hide the unclaimed destination. + */ + @Test + void anUnknownPromptFollowsATerminalSwitchIntoAnUnclaimedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + // A second tab launched the same saved file and only follows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user resumes the saved conversation b from the terminal and + // submits a prompt recalled from the history. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 9_000L); + controller.simulateTerminalInput( + new byte[]{0x1b, '[', 'A'}, START_MS + 10_000L); + assertNull(controller.simulateTerminalInput( + new byte[]{'\r'}, START_MS + 11_000L)); + + // The other launcher's own turn lands in the shared launch file. + appendUserPrompt(dir, "a", "continue", START_MS + 11_400L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_500L); + // This tab's submission lands in the resumed saved conversation, + // which no live tab holds, strictly later. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "a terminal-driven switch must follow the unclaimed destination"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + + /** + * An unheld file is not proof on its own: a tab that submitted its own + * prompt and exited leaves one behind, and it must not replace the shared + * conversation on screen when it was written earlier. + */ + @Test + void anUnheldOlderRecordDoesNotDisplaceTheSharedConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + writeSession(dir, "c", "c"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + + // The live sibling on b. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b", "c")); + + // This tab launched the saved conversation a. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b", "c")); + + try { + // The user switches to the live conversation b from the GUI. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // A recalled prompt: its text is unknown. + controller.simulatePromptSubmittedAt(START_MS + 11_000L); + controller.simulatePromptText(null); + + // A tab that submitted its own prompt and exited leaves an unheld + // record behind, earlier than this tab's submission. + appendUserPrompt(dir, "c", "third", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + // The live conversation holds the actual submission, strictly + // later. + appendUserPrompt(dir, "b", "recalled", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "an older unheld record must not displace the shared conversation"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the shared conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * A conversation this tab resolved after a GUI switch stays selected. The * switch marker lasts until the run ends, so a later recalled prompt in From 7124a474460e6e2cdb34909e21838840c691144e Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 7 Nov 2026 17:59:33 +0300 Subject: [PATCH 061/111] Limited launch ownership to the launch conversation and tracked picker answers A launch-known tab owns only the conversation it launched on: a sibling's file it starts following stays borrowed, so the tab that created it keeps it. A standalone Esc that cancelled the picker is consumed by the next input even when that input is a GUI submission, so the picker's search text no longer prefixes the prompt sent from the panel. An Enter that answers a choice menu in the terminal is a conversation switch: the /resume picker changes the conversation that way even when the command that opened it was recalled or completed and is not recognised. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 52 ++++++- .../ClaudeSessionControllerTest.java | 141 ++++++++++++++++++ 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 14fe6cb4..30621e7f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -350,6 +350,15 @@ private static final class SessionClaims { */ private boolean terminalEscapeOnMenu; + /** + * True when the last Enter confirmed a choice menu selection instead + * of submitting a prompt. The {@code /resume} picker changes the + * conversation that way, even when the command that opened it was recalled + * from the history or completed by Tab and is therefore not recognised as + * a command. + */ + private boolean terminalMenuSelection; + /** * True while a bracketed paste ({@code ESC [ 200 ~} … {@code ESC [ 201 ~}) * is being recorded: its newlines belong to the pasted prompt and must not @@ -1638,6 +1647,7 @@ private void resetTerminalInputRecording() { terminalInputPaste = false; terminalPasteAfterCR = false; terminalEscapeOnMenu = false; + terminalMenuSelection = false; terminalLineEntered = false; terminalEnterOnEmptyLine = false; terminalHistoryRecall = false; @@ -1694,11 +1704,14 @@ private void claimSession(java.io.File dir, java.nio.file.Path configDir, if (!updated.holders.contains(this)) { // Classify only a new hold: re-claiming the identity on every // scan must not turn an owner into a borrower of its own file. - if (launchIdentityKnown && !heldAsOwnerByAnotherLiveTab(updated)) { - // An explicit --continue/--resume knows its conversation. - // A NEW tab's provisional guess — or a borrower following - // one — does not establish the file, so it must not demote - // this claim to borrowing. + if (launchIdentityKnown && sessionId.equals(launchSessionId) + && !heldAsOwnerByAnotherLiveTab(updated)) { + // An explicit --continue/--resume knows the conversation it + // launched on, and only that one: a conversation adopted + // later (a sibling's file this tab started following) is + // not established by it and must stay borrowed, or the + // sibling that created it would lose it. A NEW tab's + // provisional guess does not establish the file either. updated.owners.add(this); } else if (heldByAnotherLiveTab(updated)) { // Another tab is already writing this conversation; this @@ -1996,6 +2009,14 @@ 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. + discardPickerSearchText(); + terminalInputEscape = 0; + } String draft = terminalInputLine.toString(); int cursor = Math.min(terminalInputCursor, draft.length()); submitted = draft.substring(0, cursor) + text + draft.substring(cursor); @@ -3512,7 +3533,8 @@ private void writePty(String text) throws IOException { * 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. + * 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) @@ -3523,6 +3545,7 @@ private String onTerminalInput(byte[] bytes, long now) { boolean entered; boolean emptyEnter; boolean recalled; + boolean menuSelection; synchronized (terminalInputLine) { terminalLineEntered = false; terminalEnterOnEmptyLine = false; @@ -3533,10 +3556,18 @@ private String onTerminalInput(byte[] bytes, long now) { terminalEnterOnEmptyLine = false; recalled = terminalRecalledSubmission; terminalRecalledSubmission = false; + menuSelection = terminalMenuSelection; + terminalMenuSelection = false; } if (entered && !emptyEnter) { externalInputAt = now; } + if (menuSelection) { + // Enter with a choice menu open selected an item. The /resume + // picker changes the conversation that way, and nothing else in the + // input tells which conversation was picked. + conversationSwitchAt = now; + } if (submitted != null) { if (isConversationCommand(submitted)) { // A resume command typed in the terminal is not a prompt the @@ -3683,6 +3714,7 @@ private String appendTerminalInput(byte[] bytes) { } else if (c == '\r' || c == '\n') { String line = terminalInputLine.toString().trim(); boolean unknownText = terminalHistoryRecall || terminalCompletionEdited; + boolean onMenu = model.getActiveChoiceMenu() != null; terminalHistoryRecall = false; terminalCompletionEdited = false; terminalInputLine.setLength(0); @@ -3693,7 +3725,8 @@ private String appendTerminalInput(byte[] bytes) { // history recall nor a picker selection, so it must not // invalidate the evidence of the prompt already submitted. terminalEnterOnEmptyLine = line.isEmpty() && !unknownText - && model.getActiveChoiceMenu() == null; + && !onMenu; + terminalMenuSelection = onMenu; if (unknownText) { // The line was filled from the TUI history (Up/Down) or // completed by Tab and may have been edited since: the @@ -3701,7 +3734,10 @@ private String appendTerminalInput(byte[] bytes) { // so its text is unknown and an in-process resume must be // followed by file times. terminalRecalledSubmission = true; - } else if (!line.isEmpty()) { + } else if (!line.isEmpty() && !onMenu) { + // 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. submitted = line; } } else if (c == 0x7f || c == 0x08) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 92db614c..ac1ec12c 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3977,6 +3977,147 @@ void aPickerSearchCancelledLiveDoesNotPrefixTheNextPrompt() { "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 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", 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(); + } + } + /** * 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 From be11e7189352af70063565b77f09c7d7b35db506 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sun, 8 Nov 2026 15:01:04 +0300 Subject: [PATCH 062/111] Preferred the launched conversation over a borrowed one and detected composed resume commands A launch-known tab that borrows a NEW sibling's provisional conversation while both submitted identical text kept that borrowed file as its own preference: the own-record search never ran, so the launch file that actually holds the prompt was ignored even when it was strictly newer. The tracked identity now yields to the launch ID, but only for a tab that knows the conversation it launched on; two unresolved NEW tabs still keep the shared file one of them owns provisionally. A /resume command assembled from a terminal draft and the panel text was not recognised as a conversation command, because only the panel text was classified. The composed line is classified when its text is exact, so the switch marker is set and the unclaimed destination is followed by the next unknown prompt. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 22 +++- .../ClaudeSessionControllerTest.java | 113 ++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 30621e7f..7aba3eef 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1144,14 +1144,26 @@ boolean refreshSessionIdentity() { // other live tab established and this tab does not merely // follow counts: an explicit resume of the same file by // another tab owns it. + // + // The tracked identity is subject to the same rule only when + // this tab knows the conversation it launched on: two + // unresolved tabs that submitted identical text legitimately + // keep the file one of them owns provisionally, but a + // launch-known tab has its own conversation to prefer over a + // provisional file it merely borrows. + boolean isActiveYielding = launchIdentityKnown && launchSessionId != null + && activeId != null && !activeId.equals(launchSessionId) + && isBorrowedByThisTab(dir, configDir, activeId); String ownPreferred = null; if (ownSessionId != null && !otherOwned.contains(ownSessionId) && !isBorrowedByThisTab(dir, configDir, ownSessionId)) { ownPreferred = ownSessionId; - } else if (activeId != null + } else if (activeId != null && !isActiveYielding && (activeId.equals(launchSessionId) || !otherOwned.contains(activeId))) { ownPreferred = activeId; + } else if (launchIdentityKnown && launchSessionId != null) { + ownPreferred = launchSessionId; } newest = findPromptBearingSession(dir, configDir, since, ownExclude, ownPreferred, promptAt, promptText); @@ -2027,12 +2039,14 @@ private String reconcileGuiSend(String text, long sentAt) { isTextExact = !terminalHistoryRecall && !terminalCompletionEdited; } resetTerminalInputRecording(); - if (isConversationCommand(text)) { + if (isConversationCommand(isTextExact ? submitted : text)) { // 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 panel text is checked, not the composed line: - // a draft must not hide the command. + // 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. conversationSwitchAt = sentAt; } rememberSentPrompt(submitted, sentAt, isTextExact); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index ac1ec12c..67dd210e 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4118,6 +4118,119 @@ void aRecalledResumeCommandFollowsTheUnclaimedDestination() throws Exception { } } + /** + * A provisional conversation borrowed from a NEW sibling is not this tab's + * own: preferring it would hide the launch file that actually holds the + * prompt, even though that file is strictly newer. + */ + @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. + 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()); + + // 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(); + } + } + + /** + * 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)); + + // 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(); + } + } + /** * 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 From dc3ba582113ab6743ea41e18d0ddce57de08d2a0 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 10:31:05 +0300 Subject: [PATCH 063/111] Honored scan exclusions for the preferred session and closed GUI recording at the Enter The preferred-session shortcut checked the file's prompt evidence before the candidate set built from the scan's exclusions. A tab that stopped its writer released the claim that marked a temporarily borrowed sibling file as borrowed; the final post-stop lookup then preferred that file's ID even though the sibling still holds it and claimedByOthers() had already excluded it, renaming the sibling's conversation instead of the launch conversation flushed strictly later during the stop. The shortcut now applies only while the preferred ID is still among the candidates. sendPrompt() writes the panel text and its Enter in two writes 200 ms apart. Reconciliation resets the terminal recorder before the Enter, so keys typed in that pause were recorded as a draft of the next prompt and the plugin-marked Enter never consumed them, while the panel text stayed recorded as the exact submitted text. completeGuiSend() now writes the Enter and resets the recorder under the recorder's lock, and marks the submitted text unknown when keys arrived in between. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 47 +++++++++- .../ClaudeSessionControllerTest.java | 85 +++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 7aba3eef..9e47013f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1456,7 +1456,12 @@ private String findPromptBearingSession(java.io.File dir, java.nio.file.Path con Set candidates, String preferred, long promptAt, String promptText) { if (candidates.isEmpty() || promptText == null || promptText.isBlank()) return null; - if (preferred != null && containsPrompt(dir, configDir, preferred, promptAt, promptText)) { + // The preferred session may have been excluded from the candidates + // (e.g. it is claimed by another live tab): it is checked first only + // while it is still eligible. Returning it otherwise would rename that + // tab's conversation instead of the one this run actually holds. + if (preferred != null && candidates.contains(preferred) + && containsPrompt(dir, configDir, preferred, promptAt, promptText)) { return preferred; } for (String sessionId : candidates) { @@ -1992,12 +1997,16 @@ public void sendPrompt(String text) { // duplicate it. writePty(text); Thread.sleep(200); - writePty("\r"); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); + resetTerminalInputRecording(); + return; } catch (InterruptedException ie) { Thread.currentThread().interrupt(); + resetTerminalInputRecording(); + return; } + completeGuiSend(); }, "pty-send-prompt"); t.setDaemon(true); t.start(); @@ -2053,6 +2062,40 @@ private String reconcileGuiSend(String text, long sentAt) { return submitted; } + /** + * 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. They also make the submitted text unknown: the composed evidence + * recorded by {@link #reconcileGuiSend} no longer describes that line. + */ + void completeGuiSend() { + boolean extra; + synchronized (terminalInputLine) { + try { + writePty("\r"); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + resetTerminalInputRecording(); + return; + } + extra = terminalInputLine.length() > 0; + resetTerminalInputRecording(); + } + if (extra) { + // Keys arrived while the panel text was being submitted and are + // recorded here, but their position relative to the panel text is + // not known: only the file times and user records can identify the + // conversation the line went to. + lastPromptText = null; + promptSubmittedAt = System.currentTimeMillis(); + scheduleIdentityRefresh(); + } + } + /** * Records a prompt sent from the plugin as the evidence the identity scan * matches: its text and the submission time. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 67dd210e..1a37f1dc 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3831,6 +3831,91 @@ void aRoundedOwnFileStillWinsOverASiblingWithTheSamePrompt() throws Exception { } } + /** + * Stopping releases this tab's claims, which also erases the fact that a + * conversation was only borrowed. The final post-stop lookup must still + * not return that borrowed conversation: another live tab holds it, so the + * launch conversation flushed strictly later during the stop is the one + * being renamed. + */ + @Test + void aBorrowedConversationReleasedAtStopDoesNotWinTheFinalLookup() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + // A NEW sibling resolves the conversation it created and holds it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, null, Set.of("a")); + + try { + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + assertEquals("b", tabB.resolveActiveSessionId(), + "the sibling resolves the conversation it created"); + + // Both tabs submit the same prompt: this tab can only borrow the + // sibling's file, which already holds the text. + controller.simulateGuiSend("continue", START_MS + 10_000L); + assertEquals("b", controller.resolveActiveSessionId(), + "the identical prompt makes this tab follow the sibling"); + + Set held = controller.claimedSessionIds(work.toFile(), config); + assertFalse(held.contains("b"), + "the sibling's conversation is only borrowed and is not carried over"); + + // The writer exits, releasing the borrowed classification, and the + // launch conversation is flushed strictly later: the rename must + // target it, not the borrowed file another tab still holds. + controller.simulateProcessExit(); + appendUserPrompt(dir, "a", "continue", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 13_000L); + + assertEquals("a", controller.resolveIdentityWithPreservedClaims(held), + "the launch conversation flushed during the stop must be renamed"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The GUI submits a prompt with two writes separated by a pause. Keys typed + * within that pause are part of the line the Enter submits, but the TUI + * Enter itself never reaches the parser (the plugin marks its own writes). + * They must not stay recorded as the draft of the next prompt, and the + * panel text must not pass as the exact submitted text. + */ + @Test + void keysTypedWhileTheGuiPromptIsBeingSubmittedDoNotBecomeTheNextPrompt() { + controller.simulateGuiSend("panel text", START_MS); + + // Keys typed while the panel text is being submitted. + controller.simulateTerminalInput( + " and typed".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L); + controller.completeGuiSend(); + + assertNull(controller.simulateLastPromptText(), + "the line the Enter submitted is not the panel text alone"); + assertEquals("next", + controller.simulateTerminalInput( + "next\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 300L), + "keys from the previous submission must not stay in the draft"); + } + /** Clearing the input with Ctrl+U discards a recalled prompt: the next text is known. */ @Test void clearingARecalledPromptMakesTheNextSubmissionKnown() { From ef8c8c47ac17681baace7007f32a3f5fb5803149 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 10:51:39 +0300 Subject: [PATCH 064/111] Timed the GUI Enter before writing it and invalidated evidence on any gap input completeGuiSend() sampled the submission time after forwarding the Enter. The child may persist the submitted prompt as soon as it reads the bytes, so that timestamp could be later than the user record, and the record cutoff is not relaxed by the mtime slack; after a not-yet-resolved resume into a live sibling's conversation the lookup then renamed the previous conversation. The time is now sampled before the Enter is written. The decision to drop the exact evidence used the remaining recorded buffer, which cannot see nonprinting edits during the 200 ms pause: a Backspace on the freshly reset recorder deletes a character of the panel text while leaving the buffer empty, so "hello" stayed recorded as the exact prompt although the TUI submitted "hell". The recorder now notes any terminal input since its reset and completeGuiSend() marks the text unknown on any input, whatever the buffer holds. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 59 +++++++++++++++---- .../ClaudeSessionControllerTest.java | 47 +++++++++++++++ 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 9e47013f..349e8063 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -102,6 +102,7 @@ public class ClaudeSessionController { private volatile ClaudeProcess claudeProcess; private PtyTtyConnector connector; + private volatile java.util.function.Consumer ptyWriteHook; private java.io.File workingDir; private volatile JediTermWidget terminalWidget; @@ -386,6 +387,16 @@ private static final class SessionClaims { /** Set when Enter submitted a history-recall or completion-edited line the parser did not record. */ private boolean terminalRecalledSubmission; + /** + * True when the terminal received any input 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; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -1670,6 +1681,7 @@ private void resetTerminalInputRecording() { terminalHistoryRecall = false; terminalCompletionEdited = false; terminalRecalledSubmission = false; + terminalInputSinceGuiSend = false; } } @@ -2069,12 +2081,20 @@ private String reconcileGuiSend(String text, long sentAt) { *

    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. They also make the submitted text unknown: the composed evidence - * recorded by {@link #reconcileGuiSend} no longer describes that line. + * line. Any such input, even an edit that leaves the recorded line empty + * (Backspace, cursor keys, Tab), makes the submitted text unknown: the + * composed evidence recorded by {@link #reconcileGuiSend} no longer + * describes that line. */ void completeGuiSend() { - boolean extra; + boolean edited; + long submittedAt; synchronized (terminalInputLine) { + // 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. + submittedAt = System.currentTimeMillis(); try { writePty("\r"); } catch (IOException ex) { @@ -2082,16 +2102,16 @@ void completeGuiSend() { resetTerminalInputRecording(); return; } - extra = terminalInputLine.length() > 0; + edited = terminalInputSinceGuiSend; resetTerminalInputRecording(); } - if (extra) { - // Keys arrived while the panel text was being submitted and are - // recorded here, but their position relative to the panel text is - // not known: only the file times and user records can identify the - // conversation the line went to. + 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 = System.currentTimeMillis(); + promptSubmittedAt = submittedAt; scheduleIdentityRefresh(); } } @@ -3560,6 +3580,11 @@ private void sendCancelToPty(byte[] bytes) throws IOException { * captured by {@link #sendPrompt(String)}. */ private void writePty(byte[] bytes) throws IOException { + java.util.function.Consumer hook = ptyWriteHook; + if (hook != null) { + hook.accept(bytes); + return; + } if (connector == null) { // No PTY yet (the launch is still being prepared): drop the write, // as sendPrompt() does. @@ -3575,6 +3600,15 @@ private void writePty(byte[] bytes) throws IOException { } } + /** + * Package-private for tests: replaces the PTY write, so a test can make the + * write take time or observe when it happens. A {@code null} hook restores + * the real connector. + */ + void simulatePtyWrite(java.util.function.Consumer hook) { + ptyWriteHook = hook; + } + /** Writes text to the PTY on behalf of the plugin. See {@link #writePty(byte[])}. */ private void writePty(String text) throws IOException { writePty(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); @@ -3604,6 +3638,11 @@ private String onTerminalInput(byte[] bytes, long now) { boolean recalled; boolean menuSelection; synchronized (terminalInputLine) { + // 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. + terminalInputSinceGuiSend = true; terminalLineEntered = false; terminalEnterOnEmptyLine = false; submitted = appendTerminalInput(bytes); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 1a37f1dc..e63f892b 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3916,6 +3916,53 @@ void keysTypedWhileTheGuiPromptIsBeingSubmittedDoNotBecomeTheNextPrompt() { "keys from the previous submission must not stay in the draft"); } + /** + * A nonprinting edit during the GUI send's pause changes the TUI line + * without leaving anything in the recorded draft: Backspace on the freshly + * reset recorder deletes a character of the panel text. The submitted text + * is then no longer the composed evidence. + */ + @Test + void aNonprintingEditDuringTheGuiSendMakesItsTextUnknown() { + controller.simulateGuiSend("hello", START_MS); + + // Backspace during the pause: the TUI line becomes "hell". + controller.simulateTerminalInput(new byte[]{0x7f}, START_MS + 100L); + controller.completeGuiSend(); + + assertNull(controller.simulateLastPromptText(), + "the line the Enter submits is no longer the panel text"); + } + + /** + * The child can persist the submitted prompt as soon as it reads the Enter, + * so the submission cutoff must be sampled before the bytes are forwarded. + * Sampling it after the write makes the cutoff later than the user record + * and the evidence lookup rejects the conversation's own file. + */ + @Test + void theGuiPromptIsTimestampedBeforeTheEnterIsWritten() { + long[] writeStarted = new long[1]; + controller.simulatePtyWrite(bytes -> { + writeStarted[0] = System.currentTimeMillis(); + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + controller.simulateGuiSend("hello", START_MS + 1L); + controller.simulateTerminalInput( + " world".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 2L); + controller.completeGuiSend(); + + assertTrue(writeStarted[0] > 0L, "the Enter must be written"); + assertTrue(controller.simulatePromptSubmittedAtValue() <= writeStarted[0], + "the submission time must be captured before the Enter is forwarded"); + } + /** Clearing the input with Ctrl+U discards a recalled prompt: the next text is known. */ @Test void clearingARecalledPromptMakesTheNextSubmissionKnown() { From 6d641b79affd428d7fe67e324afa95757c36f457 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 11:26:38 +0300 Subject: [PATCH 065/111] Serialized terminal writes with the GUI Enter and kept the first submission's time Raw terminal input could reach the child before the GUI prompt's Enter while its listener ran after the recorder was reset for that Enter, leaving those bytes behind as the next line's draft. The connector now forwards terminal writes and notifies the listener under the same lock the controller takes for the GUI Enter. A terminal Enter pressed during the GUI send's pause submits the written panel text before the delayed GUI Enter does. That submission's time is now kept instead of being re-timestamped at the later no-op Enter, which had pushed the evidence cutoff past the user record the child wrote. A launch-known tab whose file is claimed by another live tab after it adopted the file keeps preferring its launch conversation: the claim registry cannot mark the earlier holder as a borrower retroactively, so the scan now treats a non-launch file another live tab holds as followed rather than owned. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 104 +++++++++++++++--- .../process/PtyTtyConnector.java | 35 ++++++ .../ClaudeSessionControllerTest.java | 91 +++++++++++++++ .../process/PtyTtyConnectorTest.java | 47 ++++++++ 4 files changed, 263 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 349e8063..3816d915 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -388,15 +388,24 @@ private static final class SessionClaims { private boolean terminalRecalledSubmission; /** - * True when the terminal received any input 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. + * 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; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -552,6 +561,16 @@ void simulateLiveSession(java.nio.file.Path configDir, String sessionId, claimSession(workingDir, configDir, sessionId); } + /** + * Package-private for tests: adds a claim on {@code sessionId} to a live + * session that has already been started, i.e. the bookkeeping a scan + * publishes after it resolved the conversation. + */ + void simulateClaimSession(java.nio.file.Path configDir, String sessionId) { + claimSession(workingDir != null ? workingDir : model.getWorkingDirectory(), + configDir, sessionId); + } + /** Package-private for tests: injects a stripped PTY line into the rolling buffer. */ void simulatePtyLine(String line) { synchronized (recentPtyLines) { @@ -766,6 +785,11 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr 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); @@ -1162,11 +1186,13 @@ boolean refreshSessionIdentity() { // keep the file one of them owns provisionally, but a // launch-known tab has its own conversation to prefer over a // provisional file it merely borrows. - boolean isActiveYielding = launchIdentityKnown && launchSessionId != null - && activeId != null && !activeId.equals(launchSessionId) - && isBorrowedByThisTab(dir, configDir, activeId); + boolean isActiveYielding = isSharedGuess(activeId, otherLiveClaims) + || (launchIdentityKnown && launchSessionId != null + && activeId != null && !activeId.equals(launchSessionId) + && isBorrowedByThisTab(dir, configDir, activeId)); String ownPreferred = null; if (ownSessionId != null && !otherOwned.contains(ownSessionId) + && !isSharedGuess(ownSessionId, otherLiveClaims) && !isBorrowedByThisTab(dir, configDir, ownSessionId)) { ownPreferred = ownSessionId; } else if (activeId != null && !isActiveYielding @@ -1192,6 +1218,7 @@ boolean refreshSessionIdentity() { // this run created it: it is another live tab's conversation, // matched because both tabs submitted the same text. boolean ownFileMatch = newest != null + && !isSharedGuess(newest, otherLiveClaims) && !isBorrowedByThisTab(dir, configDir, newest); if (newest != null && (externalAt > 0 || switchAt > 0) && otherLiveClaims.contains(newest)) { @@ -1682,6 +1709,7 @@ private void resetTerminalInputRecording() { terminalCompletionEdited = false; terminalRecalledSubmission = false; terminalInputSinceGuiSend = false; + terminalEnterSinceGuiSend = false; } } @@ -1714,6 +1742,21 @@ private boolean isBorrowedByThisTab(java.io.File dir, java.nio.file.Path configD return claims != null && claims.borrowed.contains(this); } + /** + * Returns whether {@code sessionId} is a conversation this launch-known tab + * can only be following: a file other than the launch identity that another + * live tab holds as well. The claim registry marks a tab as a borrower only + * if the other tab had already claimed the file when this tab adopted it, + * so two tabs submitting the same text at once can both end up as + * provisional holders; whichever claimed the file first must not let it + * outrank the launch conversation that actually holds the prompt. + */ + private boolean isSharedGuess(String sessionId, Set otherLiveClaims) { + return sessionId != null && launchIdentityKnown && launchSessionId != null + && !sessionId.equals(launchSessionId) + && otherLiveClaims.contains(sessionId); + } + private static SessionKey keyFor(java.io.File dir, java.nio.file.Path configDir, String sessionId) { if (dir == null || configDir == null || sessionId == null) return null; @@ -2085,9 +2128,16 @@ private String reconcileGuiSend(String text, long sentAt) { * (Backspace, cursor keys, Tab), makes the submitted text unknown: the * composed evidence recorded by {@link #reconcileGuiSend} no longer * describes that line. + * + *

    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. */ void completeGuiSend() { boolean edited; + boolean alreadySubmitted; long submittedAt; synchronized (terminalInputLine) { // Sampled before the Enter is forwarded: the child may append the @@ -2103,8 +2153,24 @@ void completeGuiSend() { return; } edited = terminalInputSinceGuiSend; + alreadySubmitted = terminalEnterSinceGuiSend; resetTerminalInputRecording(); } + if (alreadySubmitted) { + // The terminal's own Enter submitted the line before the panel's + // Enter reached the TUI. That submission's time — recorded by the + // terminal path, or the panel's send time when the Enter hit the + // freshly reset recorder — is kept: 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 it + // cannot serve as evidence. + if (edited) { + lastPromptText = null; + } + scheduleIdentityRefresh(); + return; + } if (edited) { // Keys arrived while the panel text was being submitted: their // position relative to the panel text is not known, so only the @@ -3638,11 +3704,6 @@ private String onTerminalInput(byte[] bytes, long now) { boolean recalled; boolean menuSelection; synchronized (terminalInputLine) { - // 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. - terminalInputSinceGuiSend = true; terminalLineEntered = false; terminalEnterOnEmptyLine = false; submitted = appendTerminalInput(bytes); @@ -3711,6 +3772,20 @@ private String appendTerminalInput(byte[] bytes) { boolean carriedEscape = terminalInputEscape == 1; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); + // 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 @@ -3811,6 +3886,7 @@ private String appendTerminalInput(byte[] bytes) { String line = terminalInputLine.toString().trim(); boolean unknownText = terminalHistoryRecall || terminalCompletionEdited; boolean onMenu = model.getActiveChoiceMenu() != null; + terminalEnterSinceGuiSend = true; terminalHistoryRecall = false; terminalCompletionEdited = false; terminalInputLine.setLength(0); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java b/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java index 5ae4e559..4dc00956 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnector.java @@ -49,6 +49,24 @@ public final class PtyTtyConnector implements TtyConnector { * started; {@code null} means no listener. */ private volatile java.util.function.BiConsumer inputListener; + /** + * Lock held while bytes are forwarded to the child and the input listener + * is notified; {@code null} means no serialization. The controller passes + * the lock it also holds while it writes the GUI prompt's Enter, so + * terminal input cannot reach the child on one side of that Enter while + * being recorded on the other. + */ + private volatile Object inputLock; + + /** + * Sets the lock that serializes forwarding with the plugin's own writes. + * + * @param lock the lock, or {@code null} for none + */ + public void setInputLock(Object lock) { + this.inputLock = lock; + } + /** * Creates a connector for the given PTY process. * @@ -135,6 +153,23 @@ public int read(char[] buf, int offset, int length) throws IOException { @Override public void write(byte[] bytes) throws IOException { + // Terminal input and the plugin's own writes (in particular the GUI + // prompt's Enter) must reach the child in the same order in which they + // are recorded: forwarding bytes before the GUI Enter while notifying + // the listener only after the plugin reset its recorder would leave + // those bytes behind as the draft of the next line. The controller + // takes the same lock for the GUI Enter. + Object lock = inputLock; + if (lock == null) { + forward(bytes); + return; + } + synchronized (lock) { + forward(bytes); + } + } + + private void forward(byte[] bytes) throws IOException { // Sampled before the bytes are forwarded: the child can append the // submitted prompt to its session file as soon as it reads them, so a // timestamp taken after the write/listener could be later than the diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index e63f892b..cdd737e1 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3963,6 +3963,48 @@ void theGuiPromptIsTimestampedBeforeTheEnterIsWritten() { "the submission time must be captured before the Enter is forwarded"); } + /** + * If the user submits the line from the terminal during the GUI send's + * pause, the panel's Enter only submits an empty line afterwards. That + * later, no-op Enter must not re-time the submission: the cutoff would + * then be later than the user record the child wrote for the terminal + * Enter. + */ + @Test + void aTerminalEnterDuringTheGuiSendKeepsTheFirstSubmissionTime() { + controller.simulateGuiSend("hello", START_MS); + + // The terminal submits the line the panel text was written to. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.completeGuiSend(); + + assertEquals("hello", controller.simulateLastPromptText(), + "the terminal Enter submitted exactly the panel text"); + assertEquals(START_MS, controller.simulatePromptSubmittedAtValue(), + "the panel's later Enter must not re-time the submission"); + } + + /** + * Text typed during the pause and then submitted by the terminal Enter is + * only a part of the line (the panel text is not in the recorder), so the + * text becomes unknown while the first submission's time is kept. + */ + @Test + void terminalTextSubmittedByTheGapEnterKeepsItsOwnTime() { + controller.simulateGuiSend("hello", START_MS); + + controller.simulateTerminalInput( + " world".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 50L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.completeGuiSend(); + + assertNull(controller.simulateLastPromptText(), + "the recorded text is only a part of the submitted line"); + assertEquals(START_MS + 100L, controller.simulatePromptSubmittedAtValue(), + "the terminal Enter's time is the submission time"); + } + /** Clearing the input with Ctrl+U discards a recalled prompt: the next text is known. */ @Test void clearingARecalledPromptMakesTheNextSubmissionKnown() { @@ -4301,6 +4343,55 @@ void aBorrowedActiveConversationIsNotPreferred() throws Exception { } } + /** + * 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java index 998a2bc8..82433613 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/PtyTtyConnectorTest.java @@ -129,6 +129,53 @@ void testSubmissionTimeIsSampledBeforeTheBytesAreDelivered() throws Exception { "the submission time must be sampled before the bytes reach the child"); } + /** + * The controller writes the GUI prompt's Enter under the lock it also uses + * for the terminal recorder. A concurrent terminal write must therefore be + * forwarded and recorded as one step: it may not reach the child on one + * side of that Enter while its listener runs on the other, because the + * listener would then record bytes for a line the plugin already reset. + */ + @Test + void testWriteIsSerializedWithTheSharedInputLock() throws Exception { + RecordingPtyProcess p = new RecordingPtyProcess(); + PtyTtyConnector c = new PtyTtyConnector(p); + List received = + java.util.Collections.synchronizedList(new ArrayList<>()); + c.setInputListener((bytes, sentAt) -> + received.add(new String(bytes, StandardCharsets.UTF_8))); + Object lock = new Object(); + c.setInputLock(lock); + + java.util.concurrent.CountDownLatch done = + new java.util.concurrent.CountDownLatch(1); + Thread writer; + synchronized (lock) { + writer = new Thread(() -> { + try { + c.write("typed"); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } finally { + done.countDown(); + } + }, "connector-write-test"); + writer.start(); + + assertFalse(done.await(200, java.util.concurrent.TimeUnit.MILLISECONDS), + "the write must wait while the shared lock is held"); + assertEquals("", p.writtenText(), + "no bytes may reach the child while the lock is held"); + assertTrue(received.isEmpty(), + "the listener must not run while the lock is held"); + } + + assertTrue(done.await(2, java.util.concurrent.TimeUnit.SECONDS), + "the write must proceed once the lock is released"); + assertEquals("typed", p.writtenText()); + assertEquals(List.of("typed"), received); + } + /** * A PTY double that records the order of the two events this test cares * about: the pending input reaching the child and the connector sampling From 1012814a67d1ce2ca051088c6ba4802bace90d9d Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 11:46:52 +0300 Subject: [PATCH 066/111] Kept a switched-to conversation as own evidence and rejected partial gap-Enter text A resume-switch recovery resolved the unheld conversation the run moved into without publishing it as the run's own file. On the next unknown-text submission the own-candidate pass was skipped (it requires a resolved own ID), so a live sibling's newer human record displaced the conversation and Save & Switch renamed the sibling's file. The recovery result is now recorded as own evidence. A terminal Enter during the GUI send's pause submits the panel text together with the keys typed there, but the terminal parser recorded only the typed part and published it as the exact prompt. An identity scan in the window before the panel's Enter could then match an unrelated conversation's file holding that part alone and keep it as own. The pending GUI send is tracked so such an Enter records an unknown-text submission immediately. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 39 +++++- .../ClaudeSessionControllerTest.java | 113 ++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 3816d915..4d1bb08f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -406,6 +406,15 @@ private static final class SessionClaims { */ private boolean terminalEnterSinceGuiSend; + /** + * 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; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -1423,6 +1432,14 @@ boolean refreshSessionIdentity() { > ClaudeSessionStore.sessionFileModified(dir.toPath(), configDir, newest))) { newest = switched; + // No other live tab holds the switched-to conversation, + // so the record in it can only have come from this + // process: it is the file this run resumed into. It + // must be remembered as the run's own, or the next + // unknown-text submission skips the own-candidate pass + // (which requires a resolved own ID) and a sibling's + // newer record displaces it. + ownEvidence = true; } } } @@ -1710,6 +1727,7 @@ private void resetTerminalInputRecording() { terminalRecalledSubmission = false; terminalInputSinceGuiSend = false; terminalEnterSinceGuiSend = false; + guiSendAwaitingEnter = false; } } @@ -2102,7 +2120,14 @@ private String reconcileGuiSend(String text, long sentAt) { // miss the real prompt. isTextExact = !terminalHistoryRecall && !terminalCompletionEdited; } - resetTerminalInputRecording(); + synchronized (terminalInputLine) { + resetTerminalInputRecording(); + // 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; + } if (isConversationCommand(isTextExact ? submitted : text)) { // A TUI command is not a prompt written to the current // conversation: /resume and /clear can switch it, and these @@ -3910,7 +3935,17 @@ private String appendTerminalInput(byte[] bytes) { // 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. - submitted = line; + if (guiSendAwaitingEnter) { + // 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. + terminalRecalledSubmission = true; + } else { + submitted = line; + } } } else if (c == 0x7f || c == 0x08) { deleteTerminalCharBeforeCursor(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index cdd737e1..0d3f594b 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4454,6 +4454,118 @@ void aComposedResumeCommandFollowsTheUnclaimedDestination() throws Exception { } } + /** + * 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.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 @@ -4519,6 +4631,7 @@ void aGuiSendConsumesTheTerminalDraftBeforeTheNextLine() { "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), From 5e76f0d580505b0d7dc5f69b70a6caf717d54872 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 12:07:27 +0300 Subject: [PATCH 067/111] Stopped a follower's guess from hiding a created session file A launch-known tab adopts a sibling's file when that sibling's prompt record lands first, but the adoption is only a guess: the tab is not an owner of the conversation and did not launch on it. claimedByOthers() treated any live holder as hiding the file from every other scan, so the NEW tab that actually created the conversation could lose it to an older file carrying the same prompt text (CI failure aLaunchKnownTabDoesNotOwnAnAdoptedConversation). Claims held only by such a follower no longer hide the file. A file was adopted because it matched the prompt text, so the tab whose process writes it must still see it and keep it. Added a deterministic regression test that fixes the claim order through the prompt seams that do not queue background scans. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 19 +++++- .../ClaudeSessionControllerTest.java | 59 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 4d1bb08f..bdda64ce 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1973,7 +1973,8 @@ private Set claimedByOthers(java.io.File dir, java.nio.file.Path configD return; } for (ClaudeSessionController holder : claims.holders) { - if (holder != this && LIVE_SESSIONS.contains(holder) && holder.isProcessLive()) { + if (holder != this && LIVE_SESSIONS.contains(holder) && holder.isProcessLive() + && !followsOnly(holder, key.sessionId(), claims)) { ids.add(key.sessionId()); return; } @@ -1982,6 +1983,22 @@ private Set claimedByOthers(java.io.File dir, java.nio.file.Path configD return ids; } + /** + * Returns whether {@code holder} only follows {@code sessionId}: it is not + * one of the conversation's owners and the conversation is not the one it + * launched on. Such a claim is a guess made from matching prompt text — + * e.g. a launch-known tab that follows a sibling's file while its own + * conversation has not been flushed yet — and must not hide that file from + * the scan of the tab whose process actually writes it, or the creator + * adopts another conversation and loses its own. + */ + private static boolean followsOnly(ClaudeSessionController holder, String sessionId, + SessionClaims claims) { + return !claims.owners.contains(holder) + && holder.launchIdentityKnown + && !sessionId.equals(holder.launchSessionId); + } + /** * Package-private for tests: session IDs this controller treats as claimed * by other live tabs in the same store. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 0d3f594b..b9b7ffcb 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4226,6 +4226,65 @@ void aLaunchKnownTabDoesNotOwnAnAdoptedConversation() throws Exception { } } + /** + * 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 From ba6bcea966fc43f8a36bbc17f5fe678040c795ce Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 12:17:08 +0300 Subject: [PATCH 068/111] Preferred the launch conversation over a followed file that holds the prompt too A launch-known tab temporarily follows a sibling's file when both submit the same text, which makes it the tab's own evidence until the sibling claims the file. If the tab's launch conversation then records the same prompt and is strictly newer, the followed file still won the preferred position, so a sibling finishing its turn on the followed file could displace the conversation the tab actually launched on (CI failure aBorrowedActiveConversationIsNotPreferred). The launch conversation now outranks the followed identity when it holds the submission and is newer: the identity yields and, with the switch recovery still intact, a genuinely resumed conversation keeps its own evidence. The regression test now records the prompt through the seams that do not queue background scans, so it reproduces the losing claim order deterministically instead of depending on which tab scanned first. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 21 +++++++++++++++++-- .../ClaudeSessionControllerTest.java | 9 +++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index bdda64ce..dca188e9 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1195,12 +1195,29 @@ boolean refreshSessionIdentity() { // keep the file one of them owns provisionally, but a // launch-known tab has its own conversation to prefer over a // provisional file it merely borrows. + // + // A launch-known tab's tracked non-launch identity is a guess + // made from matching prompt text: the sibling that actually + // writes that file may not have claimed it yet. When the + // launch conversation itself holds the submission and is + // strictly newer, it is the stronger evidence — the + // submission went there, not to the file this tab merely + // followed — so the guess yields. + boolean launchOutranksOwn = launchIdentityKnown && launchSessionId != null + && ownSessionId != null && !ownSessionId.equals(launchSessionId) + && promptText != null && !promptText.isBlank() + && ClaudeSessionStore.sessionFileModified(dir.toPath(), configDir, + launchSessionId) + > ClaudeSessionStore.sessionFileModified(dir.toPath(), configDir, + ownSessionId) + && containsPrompt(dir, configDir, launchSessionId, promptAt, promptText); boolean isActiveYielding = isSharedGuess(activeId, otherLiveClaims) || (launchIdentityKnown && launchSessionId != null && activeId != null && !activeId.equals(launchSessionId) - && isBorrowedByThisTab(dir, configDir, activeId)); + && (!activeId.equals(ownSessionId) || launchOutranksOwn)); String ownPreferred = null; - if (ownSessionId != null && !otherOwned.contains(ownSessionId) + if (ownSessionId != null && !launchOutranksOwn + && !otherOwned.contains(ownSessionId) && !isSharedGuess(ownSessionId, otherLiveClaims) && !isBorrowedByThisTab(dir, configDir, ownSessionId)) { ownPreferred = ownSessionId; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index b9b7ffcb..6ce7681a 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4382,9 +4382,12 @@ void aBorrowedActiveConversationIsNotPreferred() throws Exception { try { // Both tabs submit the same text; only the sibling's file holds it - // yet, so this tab temporarily follows it. - controller.simulateSentPrompt("continue", START_MS + 10_000L); - tabB.simulateSentPrompt("continue", START_MS + 10_000L); + // 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()); From f57e91b6a88b93859d029e8a3e79fcce4e9b21e8 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 17:52:39 +0300 Subject: [PATCH 069/111] Kept prompt evidence exact around menu answers, stale menus and GUI sends Reset the terminal input recording in the same critical section that snapshots the draft, so a raw write interleaved between the two cannot reach the TUI after the composed line was computed yet vanish from the recording. Retire a choice menu locally once the terminal input dismisses it (a digit on a numbered menu, an Enter on a non-checkbox menu, a Right that submits a checkbox menu, or an Esc cancellation). The screen poll lags by up to half a second, and input classified against a dismissed menu lost its digits or was dropped as a menu answer, disabling the prompt evidence for the conversation that was actually written. Treat Right on a checkbox menu as a submission: it dismisses the menu, so the free-text answer typed into it must not prefix the next prompt. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 94 +++++++++++++++++-- .../ClaudeSessionControllerTest.java | 35 ++++++- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index dca188e9..73c23aff 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -351,6 +351,18 @@ private static final class SessionClaims { */ private boolean terminalEscapeOnMenu; + /** + * 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. The {@code /resume} picker changes the @@ -1745,6 +1757,7 @@ private void resetTerminalInputRecording() { terminalInputSinceGuiSend = false; terminalEnterSinceGuiSend = false; guiSendAwaitingEnter = false; + terminalCompletedMenu = null; } } @@ -1757,6 +1770,46 @@ private void discardPickerSearchText() { terminalInputLine.setLength(0); terminalInputCursor = 0; terminalEscapeOnMenu = 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; + } + + /** + * 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; } /** Package-private for tests: the PTY is no longer running (Claude exited). */ @@ -2153,8 +2206,10 @@ private String reconcileGuiSend(String text, long sentAt) { // conversation never received, and the exact-match search would // miss the real prompt. isTextExact = !terminalHistoryRecall && !terminalCompletionEdited; - } - synchronized (terminalInputLine) { + // 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. resetTerminalInputRecording(); // The panel text stays on the TUI's input line until // completeGuiSend() sends its Enter. Until then a terminal Enter @@ -3859,7 +3914,7 @@ private String appendTerminalInput(byte[] bytes) { // A standalone Esc cancels a choice menu (the /resume picker): // the recorded line then holds the picker's search text, not a // prompt draft. - terminalEscapeOnMenu = model.getActiveChoiceMenu() != null; + terminalEscapeOnMenu = openChoiceMenu() != null; // 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; @@ -3944,7 +3999,8 @@ private String appendTerminalInput(byte[] bytes) { } else if (c == '\r' || c == '\n') { String line = terminalInputLine.toString().trim(); boolean unknownText = terminalHistoryRecall || terminalCompletionEdited; - boolean onMenu = model.getActiveChoiceMenu() != null; + ChoiceMenuModel openMenu = openChoiceMenu(); + boolean onMenu = openMenu != null; terminalEnterSinceGuiSend = true; terminalHistoryRecall = false; terminalCompletionEdited = false; @@ -3958,6 +4014,13 @@ private String appendTerminalInput(byte[] bytes) { terminalEnterOnEmptyLine = line.isEmpty() && !unknownText && !onMenu; terminalMenuSelection = onMenu; + 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) { // The line was filled from the TUI history (Up/Down) or // completed by Tab and may have been edited since: the @@ -3997,7 +4060,7 @@ private String appendTerminalInput(byte[] bytes) { // 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 (model.getActiveChoiceMenu() == null) { + if (openChoiceMenu() == null) { terminalCompletionEdited = true; } } else if (c >= 0x20) { @@ -4005,7 +4068,15 @@ private String appendTerminalInput(byte[] bytes) { // 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. - if (!(model.getActiveChoiceMenu() != null && c >= '0' && c <= '9')) { + ChoiceMenuModel openMenu = openChoiceMenu(); + if (openMenu != null && c >= '0' && c <= '9') { + if (!isCheckboxMenu(openMenu)) { + // The digit selected a numbered item and closed the + // menu; a checkbox menu only toggles on a digit and + // waits for its Right to submit. + completeTerminalMenu(openMenu); + } + } else { insertTerminalChar(c); } } @@ -4054,6 +4125,15 @@ private void applyCursorKey(char key) { 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() @@ -4068,7 +4148,7 @@ private void applyCursorKey(char key) { // 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. - if (terminalInputLine.isEmpty() && model.getActiveChoiceMenu() == null) { + if (terminalInputLine.isEmpty() && openChoiceMenu() == null) { terminalHistoryRecall = true; } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 6ce7681a..d2174d3c 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4108,6 +4108,10 @@ void aTabInAChoiceMenuDoesNotRecordAPrompt() throws Exception { * immediately and is consumed by the TUI, so it must not become draft text * for the prompt typed after the menu is dismissed: the malformed text * could no longer match the file the prompt was actually written to. + * + *

    The menu is left in the model on purpose: the screen is polled only + * every half second, and a prompt submitted before the poll catches up is + * still a real submission, not a menu answer. */ @Test void aMenuSelectionDigitIsNotRecordedAsPromptDraft() { @@ -4116,15 +4120,40 @@ void aMenuSelectionDigitIsNotRecordedAsPromptDraft() { new ChoiceMenuModel.Option("No", "2")), 0)); controller.simulateTerminalInput(new byte[]{'1'}, START_MS); - // The screen no longer shows the menu. - model.clearChoiceMenu(); - + // 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"); } + /** + * 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), From 4dfbd8c043cdb2e66699e4f66853e5c4c25e546e Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 18:10:48 +0300 Subject: [PATCH 070/111] Kept searchable pickers open and edited gap Enters unknown A digit retires a choice menu only when the menu's answers are the item numbers: the unnumbered /resume picker uses ARROW answers and digits as search text, so retiring it on the first digit turned the rest of the search text into prompt evidence that a later Esc could no longer discard. An edit followed by a terminal Enter during the GUI send's pause submits a line that no longer holds the panel text, but the recorder only sees the edited buffer (empty after a Backspace). Classify that Enter as a submission of unknown text immediately, so the identity scan cannot keep matching the panel text as exact evidence before completeGuiSend() runs. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 40 +++++++++++--- .../ClaudeSessionControllerTest.java | 52 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 73c23aff..303d83aa 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1812,6 +1812,31 @@ private static boolean isCheckboxMenu(ChoiceMenuModel menu) { return false; } + /** + * Returns whether a 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 menu only moves to an option on a digit, waiting for its + * submit key — retiring those on the first digit would turn the search + * text into prompt evidence. + */ + private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu) { + if (menu.options().isEmpty()) return 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 (option.hasCheckbox() || option.hasTextInput()) return false; + } + return true; + } + /** Package-private for tests: the PTY is no longer running (Claude exited). */ void simulateProcessNotRunning() { claudeProcess = new ClaudeProcess(); @@ -4001,6 +4026,12 @@ private String appendTerminalInput(byte[] bytes) { boolean unknownText = terminalHistoryRecall || terminalCompletionEdited; ChoiceMenuModel openMenu = openChoiceMenu(); boolean onMenu = openMenu != null; + // 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 = guiSendAwaitingEnter && terminalInputSinceGuiSend; terminalEnterSinceGuiSend = true; terminalHistoryRecall = false; terminalCompletionEdited = false; @@ -4012,7 +4043,7 @@ private String appendTerminalInput(byte[] bytes) { // history recall nor a picker selection, so it must not // invalidate the evidence of the prompt already submitted. terminalEnterOnEmptyLine = line.isEmpty() && !unknownText - && !onMenu; + && !onMenu && !gapEdited; terminalMenuSelection = onMenu; if (onMenu && !isCheckboxMenu(openMenu)) { // A numbered menu closes on the selection Enter (a @@ -4028,7 +4059,7 @@ private String appendTerminalInput(byte[] bytes) { // so its text is unknown and an in-process resume must be // followed by file times. terminalRecalledSubmission = true; - } else if (!line.isEmpty() && !onMenu) { + } 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. @@ -4070,10 +4101,7 @@ private String appendTerminalInput(byte[] bytes) { // with the selection and make the actual text unmatchable. ChoiceMenuModel openMenu = openChoiceMenu(); if (openMenu != null && c >= '0' && c <= '9') { - if (!isCheckboxMenu(openMenu)) { - // The digit selected a numbered item and closed the - // menu; a checkbox menu only toggles on a digit and - // waits for its Right to submit. + if (digitSelectionDismissesMenu(openMenu)) { completeTerminalMenu(openMenu); } } else { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index d2174d3c..41faded1 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3984,6 +3984,33 @@ void aTerminalEnterDuringTheGuiSendKeepsTheFirstSubmissionTime() { "the panel's later Enter must not re-time the submission"); } + /** + * An edit followed by a terminal Enter during the GUI send's pause submits + * a line that is no longer the panel text. The recorder sees only the empty + * buffer (the panel text was never recorded), so the submission must be + * classified as unknown at the Enter itself: until {@code completeGuiSend} + * runs, the identity scan would otherwise still match the panel text as + * exact evidence. + */ + @Test + void anEditedGapEnterInvalidatesTheExactEvidenceImmediately() { + controller.simulateGuiSend("hello", START_MS); + + // Backspace makes the TUI line "hell"; Enter submits it before the + // panel's own Enter. + controller.simulateTerminalInput(new byte[]{0x7f}, START_MS + 100L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 200L); + + assertNull(controller.simulateLastPromptText(), + "the edit made the submitted line differ from the panel text"); + assertEquals(START_MS + 200L, controller.simulatePromptSubmittedAtValue(), + "the terminal Enter's time is the submission time"); + + controller.completeGuiSend(); + assertNull(controller.simulateLastPromptText(), + "the panel's no-op Enter must keep the submission unknown"); + } + /** * Text typed during the pause and then submitted by the terminal Enter is * only a part of the line (the panel text is not in the recorder), so the @@ -4180,6 +4207,31 @@ void aPickerSearchCancelledLiveDoesNotPrefixTheNextPrompt() { "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 From b04dac754d5ed351b84d44aefd235cf364683122 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 18:33:14 +0300 Subject: [PATCH 071/111] Wrote the GUI prompt text atomically with its reconciliation sendPrompt() reconciled the panel text with the terminal draft on the calling thread but wrote the text on the sender thread. An Enter arriving in between submitted only the terminal draft, while the evidence recorded at reconciliation claimed the composed line was submitted; completeGuiSend() then saw the terminal Enter and kept that fabricated text. Reconcile and write the text in one step under the recorder's lock, inside the sender thread. The sender's submission time is now sampled when the write happens, so it still precedes the child's user record. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 36 ++++++++++--- .../ClaudeSessionControllerTest.java | 54 +++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 303d83aa..7d65d8b1 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -563,6 +563,15 @@ String simulateGuiSend(String text, long sentAt) { return reconcileGuiSend(text, sentAt); } + /** + * Package-private for tests: the reconciliation and the initial text write + * of {@link #sendPrompt(String)}, without the pause and Enter that + * {@link #completeGuiSend()} adds. + */ + void simulateGuiSendWithText(String text, long sentAt) throws IOException { + writeGuiPrompt(text, sentAt); + } + /** Package-private for tests: the number of identity scans started. */ long simulateScanSequence() { synchronized (processOwnershipLock) { @@ -2172,15 +2181,14 @@ public void sendPrompt(String text) { if (!text.isBlank()) { model.addPromptToHistory(text); } - reconcileGuiSend(text, System.currentTimeMillis()); Thread t = new Thread(() -> { try { - // 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. - writePty(text); + // 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, System.currentTimeMillis()); Thread.sleep(200); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); @@ -2197,6 +2205,22 @@ public void sendPrompt(String text) { t.start(); } + /** + * 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)}). + * + *

    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. + */ + private void writeGuiPrompt(String text, long sentAt) throws IOException { + synchronized (terminalInputLine) { + reconcileGuiSend(text, sentAt); + writePty(text); + } + } + /** * 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. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 41faded1..2d6f4298 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -13,6 +13,8 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -4011,6 +4013,58 @@ void anEditedGapEnterInvalidatesTheExactEvidenceImmediately() { "the panel's no-op Enter must keep the submission unknown"); } + /** + * The panel text must be on the TUI line before terminal input can be + * processed against the reconciled recorder. Otherwise an Enter arriving + * between the reconciliation and the text write submits only the terminal + * draft, while the recorded evidence claims the composed line was + * submitted. + */ + @Test + void terminalInputWaitsForTheGuiTextWrite() throws Exception { + CountDownLatch writeStarted = new CountDownLatch(1); + CountDownLatch releaseWrite = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + writeStarted.countDown(); + try { + releaseWrite.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + controller.simulateTerminalInput( + "draft".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + + AtomicReference failure = new AtomicReference<>(); + Thread sender = new Thread(() -> { + try { + controller.simulateGuiSendWithText("hello", START_MS + 1L); + } catch (Throwable e) { + failure.set(e); + } + }); + sender.start(); + assertTrue(writeStarted.await(5, TimeUnit.SECONDS), + "the panel text must be written"); + + CountDownLatch enterDone = new CountDownLatch(1); + Thread enter = new Thread(() -> { + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 2L); + enterDone.countDown(); + }); + enter.start(); + assertFalse(enterDone.await(400, TimeUnit.MILLISECONDS), + "terminal input must wait until the panel text is on the TUI line"); + releaseWrite.countDown(); + sender.join(5_000L); + enter.join(5_000L); + + assertNull(failure.get(), "the panel text write must not fail"); + assertEquals(0L, enterDone.getCount(), + "terminal input must be processed after the write"); + } + /** * Text typed during the pause and then submitted by the terminal Enter is * only a part of the line (the panel text is not in the recorder), so the From a8b86b7f1317b494e1e4a9f6ce59be92d6a07751 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 18:53:56 +0300 Subject: [PATCH 072/111] Deferred exact GUI prompt evidence until its Enter and timed it under the lock reconcileGuiSend() published the exact composed prompt as own-session evidence and scheduled an identity scan while the panel text was still waiting for its Enter. A scan in that pause could adopt another conversation's file that received the same prompt, although this process had submitted nothing. Keep the composed exact text pending and publish it from completeGuiSend(), after the Enter is written; partial (history-recall or Tab-completed) lines carry no matchable text and are still recorded immediately. The submission time is now sampled inside writeGuiPrompt()'s critical section instead of before acquiring the recorder lock, so a terminal submission that completed just before cannot leave the GUI prompt with an older promptSubmittedAt than externalInputAt. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 70 +++++++++++++++++-- .../ClaudeSessionControllerTest.java | 27 +++++++ 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 7d65d8b1..2fe9d47c 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -427,6 +427,19 @@ private static final class SessionClaims { */ private boolean guiSendAwaitingEnter; + /** + * Exact prompt of a GUI send whose Enter has not been written yet, or + * {@code null}. 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 match a file of another conversation + * that happens to receive the same prompt. Published by + * {@link #completeGuiSend()}. Guarded by {@link #terminalInputLine}. + */ + private String pendingGuiPromptText; + + /** Submission time of {@link #pendingGuiPromptText}. */ + private long pendingGuiPromptAt; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -569,7 +582,7 @@ String simulateGuiSend(String text, long sentAt) { * {@link #completeGuiSend()} adds. */ void simulateGuiSendWithText(String text, long sentAt) throws IOException { - writeGuiPrompt(text, sentAt); + writeGuiPrompt(text, () -> sentAt); } /** Package-private for tests: the number of identity scans started. */ @@ -1766,6 +1779,8 @@ private void resetTerminalInputRecording() { terminalInputSinceGuiSend = false; terminalEnterSinceGuiSend = false; guiSendAwaitingEnter = false; + pendingGuiPromptText = null; + pendingGuiPromptAt = 0L; terminalCompletedMenu = null; } } @@ -2188,7 +2203,7 @@ public void sendPrompt(String text) { // 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, System.currentTimeMillis()); + writeGuiPrompt(text); Thread.sleep(200); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); @@ -2205,18 +2220,29 @@ public void sendPrompt(String text) { t.start(); } + /** {@link #writeGuiPrompt(String, LongSupplier)} with the wall clock. */ + private void writeGuiPrompt(String text) throws IOException { + writeGuiPrompt(text, System::currentTimeMillis); + } + /** * 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. */ - private void writeGuiPrompt(String text, long sentAt) throws IOException { + private void writeGuiPrompt(String text, java.util.function.LongSupplier clock) + throws IOException { synchronized (terminalInputLine) { - reconcileGuiSend(text, sentAt); + reconcileGuiSend(text, clock.getAsLong()); writePty(text); } } @@ -2265,6 +2291,14 @@ private String reconcileGuiSend(String text, long sentAt) { // submits that text together with the keys typed here, so the // terminal parser's line is only a part of the prompt. guiSendAwaitingEnter = true; + if (isTextExact && !submitted.isBlank()) { + // Exact text must not become own-session evidence before the + // Enter is written: the process has submitted nothing yet, and + // an identity scan running during the pause could adopt + // another conversation's file that receives the same prompt. + pendingGuiPromptText = submitted; + pendingGuiPromptAt = sentAt; + } } if (isConversationCommand(isTextExact ? submitted : text)) { // A TUI command is not a prompt written to the current @@ -2276,7 +2310,11 @@ private String reconcileGuiSend(String text, long sentAt) { // then only the panel text is trusted. conversationSwitchAt = sentAt; } - rememberSentPrompt(submitted, sentAt, isTextExact); + if (!isTextExact) { + // A partial line carries no text a file could be matched against, + // only the submission cutoff, so it can be recorded right away. + rememberSentPrompt(submitted, sentAt, false); + } return submitted; } @@ -2289,8 +2327,13 @@ private String reconcileGuiSend(String text, long sentAt) { * 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 evidence recorded by {@link #reconcileGuiSend} no longer - * describes that line. + * 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. * *

    A terminal Enter pressed during that pause submits the line first, and * this Enter then submits an empty one. The evidence of the first @@ -2302,6 +2345,8 @@ void completeGuiSend() { boolean edited; boolean alreadySubmitted; long submittedAt; + String pendingText; + long pendingAt; synchronized (terminalInputLine) { // Sampled before the Enter is forwarded: the child may append the // submitted prompt to its session file as soon as it reads the @@ -2317,6 +2362,8 @@ void completeGuiSend() { } edited = terminalInputSinceGuiSend; alreadySubmitted = terminalEnterSinceGuiSend; + pendingText = pendingGuiPromptText; + pendingAt = pendingGuiPromptAt; resetTerminalInputRecording(); } if (alreadySubmitted) { @@ -2330,6 +2377,10 @@ void completeGuiSend() { // cannot serve as evidence. if (edited) { lastPromptText = null; + } else if (pendingText != null) { + // The line was not edited, so the terminal Enter submitted + // exactly the composed text the reconciliation prepared. + rememberSentPrompt(pendingText, pendingAt, true); } scheduleIdentityRefresh(); return; @@ -2342,6 +2393,11 @@ void completeGuiSend() { lastPromptText = null; promptSubmittedAt = submittedAt; scheduleIdentityRefresh(); + } else if (pendingText != null) { + // The Enter has now submitted the line the reconciliation + // composed: its exact text can finally become own-session + // evidence, with the send time as the cutoff. + rememberSentPrompt(pendingText, pendingAt, true); } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 2d6f4298..7357b1be 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3276,6 +3276,32 @@ void anUnknownPromptFollowsAGuiSwitchIntoAnUnclaimedConversation() throws Except } } + /** + * The panel text reaches the conversation only when the GUI send's Enter is + * written. Publishing it as own-session evidence sooner would let an + * identity scan running in the pause adopt another conversation that + * receives the same prompt before this process has submitted anything. + */ + @Test + void aGuiPromptBecomesEvidenceOnlyWhenItsEnterIsWritten() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + controller.simulateGuiSend("hello", START_MS); + + assertEquals("earlier", controller.simulateLastPromptText(), + "the panel text is not submitted until its Enter"); + assertEquals(START_MS - 10_000L, controller.simulatePromptSubmittedAtValue(), + "the previous submission stays the evidence meanwhile"); + + controller.completeGuiSend(); + + assertEquals("hello", controller.simulateLastPromptText(), + "the Enter makes the panel text the submitted prompt"); + assertEquals(START_MS, controller.simulatePromptSubmittedAtValue(), + "the send time is the submission cutoff"); + } + /** * A Tab-completed or recalled draft holds only a part of the submitted * line, so a GUI submission carrying it is recorded as unknown text @@ -3871,6 +3897,7 @@ void aBorrowedConversationReleasedAtStopDoesNotWinTheFinalLookup() throws Except // Both tabs submit the same prompt: this tab can only borrow the // sibling's file, which already holds the text. controller.simulateGuiSend("continue", START_MS + 10_000L); + controller.completeGuiSend(); assertEquals("b", controller.resolveActiveSessionId(), "the identical prompt makes this tab follow the sibling"); From 766303ee2a3902b2e72b7365205ffeadf00c0a05 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 19:11:48 +0300 Subject: [PATCH 073/111] Deferred unknown-text GUI evidence until its Enter and published all of it under the input lock A partial line (history recall or Tab completion) was published as unknown-text evidence by reconcileGuiSend, i.e. during the pause before the Enter was forwarded. The unknown-text resolver could then adopt any unrelated conversation in the store that received a human record in that window, by time alone, and later scans kept that file. The pending record now carries the exactness flag and every GUI submission is published only by completeGuiSend(), once its Enter is written. completeGuiSend() also published the evidence after releasing the terminal input lock. A terminal submission finishing in that gap could be overwritten by the older panel prompt, leaving promptSubmittedAt behind externalInputAt and making the identity scan skip both prompt-evidence branches. The evidence update now runs in the same critical section as the Enter and the recorder reset. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 146 ++++++++++-------- .../ClaudeSessionControllerTest.java | 11 +- 2 files changed, 93 insertions(+), 64 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 2fe9d47c..42b9139b 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -428,18 +428,25 @@ private static final class SessionClaims { private boolean guiSendAwaitingEnter; /** - * Exact prompt of a GUI send whose Enter has not been written yet, or - * {@code null}. 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 match a file of another conversation - * that happens to receive the same prompt. Published by - * {@link #completeGuiSend()}. Guarded by {@link #terminalInputLine}. + * 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; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -1781,6 +1788,8 @@ private void resetTerminalInputRecording() { guiSendAwaitingEnter = false; pendingGuiPromptText = null; pendingGuiPromptAt = 0L; + pendingGuiPromptExact = false; + pendingGuiPromptSet = false; terminalCompletedMenu = null; } } @@ -2252,10 +2261,11 @@ private void writeGuiPrompt(String text, java.util.function.LongSupplier clock) * 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. That draft is - * recorded as part of the evidence and then forgotten: the panel's - * submission consumed the terminal line, so it must not prefix the next - * line typed in the terminal either. + * 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 @@ -2291,14 +2301,16 @@ private String reconcileGuiSend(String text, long sentAt) { // submits that text together with the keys typed here, so the // terminal parser's line is only a part of the prompt. guiSendAwaitingEnter = true; - if (isTextExact && !submitted.isBlank()) { - // Exact text must not become own-session evidence before the - // Enter is written: the process has submitted nothing yet, and - // an identity scan running during the pause could adopt - // another conversation's file that receives the same prompt. - pendingGuiPromptText = submitted; - pendingGuiPromptAt = sentAt; - } + // 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; } if (isConversationCommand(isTextExact ? submitted : text)) { // A TUI command is not a prompt written to the current @@ -2310,11 +2322,6 @@ private String reconcileGuiSend(String text, long sentAt) { // then only the panel text is trusted. conversationSwitchAt = sentAt; } - if (!isTextExact) { - // A partial line carries no text a file could be matched against, - // only the submission cutoff, so it can be recorded right away. - rememberSentPrompt(submitted, sentAt, false); - } return submitted; } @@ -2333,7 +2340,18 @@ private String reconcileGuiSend(String text, long sentAt) { *

    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. + * 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 @@ -2342,17 +2360,13 @@ private String reconcileGuiSend(String text, long sentAt) { * was not edited. */ void completeGuiSend() { - boolean edited; - boolean alreadySubmitted; - long submittedAt; - String pendingText; - long pendingAt; + boolean schedule; synchronized (terminalInputLine) { // 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. - submittedAt = System.currentTimeMillis(); + long submittedAt = System.currentTimeMillis(); try { writePty("\r"); } catch (IOException ex) { @@ -2360,44 +2374,50 @@ void completeGuiSend() { resetTerminalInputRecording(); return; } - edited = terminalInputSinceGuiSend; - alreadySubmitted = terminalEnterSinceGuiSend; - pendingText = pendingGuiPromptText; - pendingAt = pendingGuiPromptAt; + boolean edited = terminalInputSinceGuiSend; + boolean alreadySubmitted = terminalEnterSinceGuiSend; + String pendingText = pendingGuiPromptText; + long pendingAt = pendingGuiPromptAt; + boolean pendingExact = pendingGuiPromptExact; + boolean pendingSet = pendingGuiPromptSet; resetTerminalInputRecording(); - } - if (alreadySubmitted) { - // The terminal's own Enter submitted the line before the panel's - // Enter reached the TUI. That submission's time — recorded by the - // terminal path, or the panel's send time when the Enter hit the - // freshly reset recorder — is kept: 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 it - // cannot serve as evidence. - if (edited) { + if (alreadySubmitted) { + // The terminal's own Enter submitted the line before the + // panel's Enter reached the TUI. That submission's time — + // recorded by the terminal path, or the panel's send time when + // the Enter hit the freshly reset recorder — is kept: + // 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 it cannot serve as evidence. + if (edited) { + lastPromptText = null; + } else if (pendingSet) { + // The line was not edited, so the terminal Enter submitted + // exactly the line the reconciliation prepared. + rememberSentPrompt(pendingText, pendingAt, pendingExact); + } + 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; - } else if (pendingText != null) { - // The line was not edited, so the terminal Enter submitted - // exactly the composed text the reconciliation prepared. - rememberSentPrompt(pendingText, pendingAt, true); + promptSubmittedAt = submittedAt; + schedule = true; + } else if (pendingSet) { + // The Enter has now submitted the line the reconciliation + // composed: it can finally become own-session evidence, with + // the send time as the cutoff. + rememberSentPrompt(pendingText, pendingAt, pendingExact); + schedule = false; + } else { + schedule = false; } - scheduleIdentityRefresh(); - return; } - 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; + if (schedule) { scheduleIdentityRefresh(); - } else if (pendingText != null) { - // The Enter has now submitted the line the reconciliation - // composed: its exact text can finally become own-session - // evidence, with the send time as the cutoff. - rememberSentPrompt(pendingText, pendingAt, true); } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7357b1be..9145e34f 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3305,7 +3305,10 @@ void aGuiPromptBecomesEvidenceOnlyWhenItsEnterIsWritten() { /** * A Tab-completed or recalled draft holds only a part of the submitted * line, so a GUI submission carrying it is recorded as unknown text - * instead of an exact prompt the conversation never received. + * instead of an exact prompt the conversation never received. Like exact + * text, it is not published before the Enter is written: an identity scan + * running in the pause could otherwise adopt an unrelated conversation by + * time alone. */ @Test void aGuiSendWithACompletedDraftRecordsUnknownText() { @@ -3318,6 +3321,12 @@ void aGuiSendWithACompletedDraftRecordsUnknownText() { assertEquals("@fi hello", controller.simulateGuiSend(" hello", START_MS + 2_000L), "the composed line is still what the conversation receives"); + assertEquals("earlier", controller.simulateLastPromptText(), + "nothing is published before the Enter is written"); + assertEquals(START_MS - 10_000L, controller.simulatePromptSubmittedAtValue(), + "the previous evidence is kept until the actual submission"); + + controller.completeGuiSend(); assertNull(controller.simulateLastPromptText(), "a completed draft makes the submitted text unknown"); assertEquals(START_MS + 2_000L, controller.simulatePromptSubmittedAtValue(), From c2433fe051aebf9e0eca4fd5c6c6afdcacb09c42 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 19:30:20 +0300 Subject: [PATCH 074/111] Timed unknown-text GUI evidence at its Enter and recorded drafts typed after an early Enter For a partially known line the only thing identifying the user record is its time. Publishing it with the text-write time as the cutoff made a human record written during the ~200 ms pause count as this tab's submission if the first post-Enter scan beat the real JSONL flush; the wrong file then became ownSessionId and later unknown-text scans stayed on it. The cutoff is now sampled immediately before the submitting Enter. Exact text keeps the send time, since the text itself identifies the record. When a terminal Enter submitted the panel text during the pause and the user typed a new draft before the delayed GUI Enter, both recorder flags were set, and the delayed Enter was treated as a no-op while the draft's submission went unrecorded, leaving promptSubmittedAt untouched on a first send. The recorder now marks input that arrives after that early Enter; a non-empty resulting line is recorded as the submission of the delayed Enter, with its time and exactness, and a deleted draft falls back to the exact panel text. An inexact pending record is no longer republished over the terminal path's own, better-timed submission. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 83 +++++++++++++++---- .../ClaudeSessionControllerTest.java | 35 +++++++- 2 files changed, 99 insertions(+), 19 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 42b9139b..f08b908d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -418,6 +418,15 @@ private static final class SessionClaims { */ 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; + /** * 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 @@ -1785,6 +1794,7 @@ private void resetTerminalInputRecording() { terminalRecalledSubmission = false; terminalInputSinceGuiSend = false; terminalEnterSinceGuiSend = false; + terminalDraftAfterEnter = false; guiSendAwaitingEnter = false; pendingGuiPromptText = null; pendingGuiPromptAt = 0L; @@ -2357,16 +2367,31 @@ private String reconcileGuiSend(String text, long sentAt) { * 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. + * 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. + * + *

    When the submitted text is only partially known, the cutoff is this + * Enter's time, not the text-write time: with no text to match, a record + * written during the pause would otherwise qualify as this submission + * before the real one has been flushed. */ void completeGuiSend() { + completeGuiSend(System::currentTimeMillis); + } + + /** + * {@link #completeGuiSend()} with a controllable clock, used by tests to + * pin the submission cutoff. + */ + void completeGuiSend(java.util.function.LongSupplier clock) { boolean schedule; synchronized (terminalInputLine) { // 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 = System.currentTimeMillis(); + long submittedAt = clock.getAsLong(); try { writePty("\r"); } catch (IOException ex) { @@ -2376,26 +2401,44 @@ void completeGuiSend() { } boolean edited = terminalInputSinceGuiSend; boolean alreadySubmitted = terminalEnterSinceGuiSend; + boolean draftAfterEnter = terminalDraftAfterEnter; + String draftLine = terminalInputLine.toString().trim(); + boolean draftExact = !terminalHistoryRecall && !terminalCompletionEdited; String pendingText = pendingGuiPromptText; long pendingAt = pendingGuiPromptAt; boolean pendingExact = pendingGuiPromptExact; boolean pendingSet = pendingGuiPromptSet; resetTerminalInputRecording(); - if (alreadySubmitted) { + if (alreadySubmitted && draftAfterEnter && !draftLine.isEmpty()) { + // The terminal Enter submitted the panel text during the pause, + // and the input typed afterwards is a new line that this Enter + // submits. The Enter cleared the recorder, so the recorded text + // is the whole line, and the submission happened after this + // Enter's time was sampled. + rememberSentPrompt(draftLine, submittedAt, draftExact); + schedule = false; + } else if (alreadySubmitted && draftAfterEnter && pendingSet + && pendingExact) { + // The draft typed after the terminal Enter was deleted again, + // so this Enter submits nothing: the panel text submitted by + // that Enter is still the exact, latest submission. + rememberSentPrompt(pendingText, pendingAt, true); + schedule = false; + } else if (alreadySubmitted) { // The terminal's own Enter submitted the line before the - // panel's Enter reached the TUI. That submission's time — - // recorded by the terminal path, or the panel's send time when - // the Enter hit the freshly reset recorder — is kept: - // 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 it cannot serve as evidence. + // panel's Enter reached the TUI, and no new line was started + // since. That submission's time is kept — 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. if (edited) { lastPromptText = null; - } else if (pendingSet) { + } else if (pendingSet && pendingExact) { // The line was not edited, so the terminal Enter submitted // exactly the line the reconciliation prepared. - rememberSentPrompt(pendingText, pendingAt, pendingExact); + rememberSentPrompt(pendingText, pendingAt, true); } schedule = true; } else if (edited) { @@ -2408,9 +2451,13 @@ void completeGuiSend() { schedule = true; } else if (pendingSet) { // The Enter has now submitted the line the reconciliation - // composed: it can finally become own-session evidence, with - // the send time as the cutoff. - rememberSentPrompt(pendingText, pendingAt, pendingExact); + // composed. Exact text keeps the send time: the text itself + // identifies the record, so the wider cutoff is harmless. A + // partially known line cannot be matched by text, and its + // cutoff must not reach back into the pause, where an + // unrelated conversation may have received a human record. + long cutoff = pendingExact ? pendingAt : submittedAt; + rememberSentPrompt(pendingText, cutoff, pendingExact); schedule = false; } else { schedule = false; @@ -4011,6 +4058,12 @@ private String appendTerminalInput(byte[] bytes) { 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 9145e34f..7c18ce68 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3308,7 +3308,9 @@ void aGuiPromptBecomesEvidenceOnlyWhenItsEnterIsWritten() { * instead of an exact prompt the conversation never received. Like exact * text, it is not published before the Enter is written: an identity scan * running in the pause could otherwise adopt an unrelated conversation by - * time alone. + * time alone. Its cutoff is the Enter time, not the send time: with no + * text to match, a record written during the pause would otherwise count + * as this submission. */ @Test void aGuiSendWithACompletedDraftRecordsUnknownText() { @@ -3326,11 +3328,36 @@ void aGuiSendWithACompletedDraftRecordsUnknownText() { assertEquals(START_MS - 10_000L, controller.simulatePromptSubmittedAtValue(), "the previous evidence is kept until the actual submission"); - controller.completeGuiSend(); + controller.completeGuiSend(() -> START_MS + 2_300L); assertNull(controller.simulateLastPromptText(), "a completed draft makes the submitted text unknown"); - assertEquals(START_MS + 2_000L, controller.simulatePromptSubmittedAtValue(), - "the submission time is still recorded"); + assertEquals(START_MS + 2_300L, controller.simulatePromptSubmittedAtValue(), + "the Enter time is the submission cutoff, not the send time"); + } + + /** + * A terminal Enter during the pause submits the panel text; a draft typed + * after it starts a new line, which the delayed Enter then submits. That + * draft is fully recorded, so it becomes the evidence with the Enter time + * instead of the delayed Enter being treated as a no-op and the second + * submission going unrecorded. + */ + @Test + void aDraftTypedAfterTheEarlyEnterIsSubmittedByTheDelayedEnter() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + controller.simulateGuiSend("hello", START_MS); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 50L); + controller.simulateTerminalInput( + "next".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS + 100L); + + controller.completeGuiSend(() -> START_MS + 250L); + + assertEquals("next", controller.simulateLastPromptText(), + "the delayed Enter submits the draft typed after the early Enter"); + assertEquals(START_MS + 250L, controller.simulatePromptSubmittedAtValue(), + "the delayed Enter's time is the submission cutoff"); } /** From ca6082ecd411cc61d305a84595c5834156a8a15d Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 19:45:20 +0300 Subject: [PATCH 075/111] Timed GUI prompt evidence at the Enter that submits it and published unknown-text pending records on an early Enter The cutoff was still the prompt-send time, ~200 ms before the submitting Enter, so a human record written by an unrelated conversation during the pause could qualify as this tab's submission before the real JSONL flush. Both exact and unknown-text evidence now use the submitting Enter's time: submittedAt for the delayed panel Enter, and the recorded time of the terminal Enter when that Enter submitted the line first. An early terminal Enter on a GUI send extending a recalled or Tab-completed draft was classified as an empty Enter: the reconciliation had reset the recall/completion flags, and the alreadySubmitted path published the pending record only for exact text, so the previous prompt stayed the evidence and the stop-time lookup renamed the wrong conversation. The pending record is now published for either kind of text, at the early Enter's time. A draft typed after the early Enter and submitted from the terminal keeps its own newer evidence: the exact panel text is no longer republished over it. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 74 +++++++++++++------ .../ClaudeSessionControllerTest.java | 41 ++++++++-- 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index f08b908d..18a7123e 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -427,6 +427,14 @@ private static final class SessionClaims { */ 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; + /** * 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 @@ -1795,6 +1803,7 @@ private void resetTerminalInputRecording() { terminalInputSinceGuiSend = false; terminalEnterSinceGuiSend = false; terminalDraftAfterEnter = false; + terminalGuiSubmitAt = 0L; guiSendAwaitingEnter = false; pendingGuiPromptText = null; pendingGuiPromptAt = 0L; @@ -2371,10 +2380,11 @@ private String reconcileGuiSend(String text, long sentAt) { * this Enter submits it: the draft is recorded — with this Enter's time — * instead of being dropped as a no-op. * - *

    When the submitted text is only partially known, the cutoff is this - * Enter's time, not the text-write time: with no text to match, a record - * written during the pause would otherwise qualify as this submission - * before the real one has been flushed. + *

    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); @@ -2402,6 +2412,7 @@ void completeGuiSend(java.util.function.LongSupplier clock) { boolean edited = terminalInputSinceGuiSend; boolean alreadySubmitted = terminalEnterSinceGuiSend; boolean draftAfterEnter = terminalDraftAfterEnter; + long guiSubmitAt = terminalGuiSubmitAt; String draftLine = terminalInputLine.toString().trim(); boolean draftExact = !terminalHistoryRecall && !terminalCompletionEdited; String pendingText = pendingGuiPromptText; @@ -2418,27 +2429,33 @@ void completeGuiSend(java.util.function.LongSupplier clock) { rememberSentPrompt(draftLine, submittedAt, draftExact); schedule = false; } else if (alreadySubmitted && draftAfterEnter && pendingSet - && pendingExact) { + && pendingExact && promptSubmittedAt <= pendingAt) { // The draft typed after the terminal Enter was deleted again, // so this Enter submits nothing: the panel text submitted by - // that Enter is still the exact, latest submission. - rememberSentPrompt(pendingText, pendingAt, true); + // that Enter is still the exact, latest submission. A newer + // submission recorded meanwhile (a draft submitted from the + // terminal) is left in place. + rememberSentPrompt(pendingText, + guiSubmitAt != 0L ? guiSubmitAt : pendingAt, true); 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's time is kept — 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. + // 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. if (edited) { lastPromptText = null; - } else if (pendingSet && pendingExact) { - // The line was not edited, so the terminal Enter submitted - // exactly the line the reconciliation prepared. - rememberSentPrompt(pendingText, pendingAt, true); + } else if (pendingSet) { + rememberSentPrompt(pendingText, + guiSubmitAt != 0L ? guiSubmitAt : pendingAt, pendingExact); } schedule = true; } else if (edited) { @@ -2451,13 +2468,11 @@ void completeGuiSend(java.util.function.LongSupplier clock) { schedule = true; } else if (pendingSet) { // The Enter has now submitted the line the reconciliation - // composed. Exact text keeps the send time: the text itself - // identifies the record, so the wider cutoff is harmless. A - // partially known line cannot be matched by text, and its - // cutoff must not reach back into the pause, where an - // unrelated conversation may have received a human record. - long cutoff = pendingExact ? pendingAt : submittedAt; - rememberSentPrompt(pendingText, cutoff, pendingExact); + // 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); schedule = false; } else { schedule = false; @@ -4001,6 +4016,17 @@ private String onTerminalInput(byte[] bytes, long now) { terminalRecalledSubmission = false; menuSelection = terminalMenuSelection; terminalMenuSelection = false; + 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. + terminalGuiSubmitAt = now; + } } if (entered && !emptyEnter) { externalInputAt = now; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7c18ce68..8d1bf281 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3281,6 +3281,8 @@ void anUnknownPromptFollowsAGuiSwitchIntoAnUnclaimedConversation() throws Except * written. Publishing it as own-session evidence sooner would let an * identity scan running in the pause adopt another conversation that * receives the same prompt before this process has submitted anything. + * The cutoff is that Enter's time, not the text-write time: a record + * written in the pause must not count as this submission. */ @Test void aGuiPromptBecomesEvidenceOnlyWhenItsEnterIsWritten() { @@ -3294,12 +3296,12 @@ void aGuiPromptBecomesEvidenceOnlyWhenItsEnterIsWritten() { assertEquals(START_MS - 10_000L, controller.simulatePromptSubmittedAtValue(), "the previous submission stays the evidence meanwhile"); - controller.completeGuiSend(); + controller.completeGuiSend(() -> START_MS + 200L); assertEquals("hello", controller.simulateLastPromptText(), "the Enter makes the panel text the submitted prompt"); - assertEquals(START_MS, controller.simulatePromptSubmittedAtValue(), - "the send time is the submission cutoff"); + assertEquals(START_MS + 200L, controller.simulatePromptSubmittedAtValue(), + "the Enter time is the submission cutoff, not the send time"); } /** @@ -3335,6 +3337,32 @@ void aGuiSendWithACompletedDraftRecordsUnknownText() { "the Enter time is the submission cutoff, not the send time"); } + /** + * The reconciliation resets the recall/completion flags, so a terminal + * Enter during the pause sees an empty line and is classified as an empty + * Enter, although it submits the composed line. The pending unknown-text + * submission must then be published at that Enter's time: skipping it + * leaves the previous prompt as the evidence and the stop-time lookup + * renames the wrong conversation. + */ + @Test + void anEarlyEnterPublishesTheUnknownTextOfACompletedDraft() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + controller.simulateTerminalInput( + "@fi".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + controller.simulateTerminalInput(new byte[]{'\t'}, START_MS + 1_000L); + controller.simulateGuiSend(" hello", START_MS + 2_000L); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 2_100L); + controller.completeGuiSend(() -> START_MS + 2_400L); + + assertNull(controller.simulateLastPromptText(), + "the completed draft makes the submitted text unknown"); + assertEquals(START_MS + 2_100L, controller.simulatePromptSubmittedAtValue(), + "the early Enter's time is the submission cutoff"); + } + /** * A terminal Enter during the pause submits the panel text; a draft typed * after it starts a new line, which the delayed Enter then submits. That @@ -3933,7 +3961,7 @@ void aBorrowedConversationReleasedAtStopDoesNotWinTheFinalLookup() throws Except // Both tabs submit the same prompt: this tab can only borrow the // sibling's file, which already holds the text. controller.simulateGuiSend("continue", START_MS + 10_000L); - controller.completeGuiSend(); + controller.completeGuiSend(() -> START_MS + 10_200L); assertEquals("b", controller.resolveActiveSessionId(), "the identical prompt makes this tab follow the sibling"); @@ -4045,8 +4073,9 @@ void aTerminalEnterDuringTheGuiSendKeepsTheFirstSubmissionTime() { assertEquals("hello", controller.simulateLastPromptText(), "the terminal Enter submitted exactly the panel text"); - assertEquals(START_MS, controller.simulatePromptSubmittedAtValue(), - "the panel's later Enter must not re-time the submission"); + assertEquals(START_MS + 100L, controller.simulatePromptSubmittedAtValue(), + "the terminal Enter's time is the cutoff; the panel's later Enter " + + "must not re-time the submission"); } /** From afd98add9efd3f79d1fb1b0c3f00c13b1cbf5748 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 20:05:10 +0300 Subject: [PATCH 076/111] Kept a resumed live conversation over an exited tab's record and recorded recalled drafts at the delayed Enter A NEW tab that resumes another live tab's conversation before creating a file of its own no longer takes an unrelated unclaimed record written after the submission as its first file: the resumed conversation is compared with it by file time and wins when it holds the actual submission. A GUI send that extends a recalled or Tab-completed draft leaves the recorder empty for the line a post-Enter recall submits. The delayed Enter now records that line as an unknown-text submission at its own time instead of dropping the submission, and the pending unknown-text submission is published on an early terminal Enter even when nothing followed it. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 60 ++++++++++--- .../ClaudeSessionControllerTest.java | 85 +++++++++++++++++++ 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 18a7123e..f8c0c4af 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1432,6 +1432,43 @@ boolean refreshSessionIdentity() { } ownEvidence = newest != null; ownRecordMatch = newest != null; + if (newest != null && switchAt > 0) { + // The conversation was switched with a resume + // command before this run created a file of its + // own, so the unclaimed record above may be an + // exited tab's own submission rather than this + // run's first file. The switch's destination is + // among the shared conversations; when it holds + // this submission and was written later, it is the + // conversation on screen, and the unclaimed record + // must not be taken as this run's own. + Set shared = new HashSet<>(claimed); + shared.addAll(otherLiveClaims); + if (activeId != null) shared.add(activeId); + if (launchIdentityKnown && launchSessionId != null) { + shared.add(launchSessionId); + } + String switchedTo = newestSharedSessionWrittenSince(dir, + configDir, since, promptAt, shared, exclude); + if (switchedTo == null + && since > SESSION_MTIME_GRANULARITY_MS) { + switchedTo = newestSharedSessionWrittenSince(dir, + configDir, since - SESSION_MTIME_GRANULARITY_MS, + promptAt, shared, exclude); + } + if (switchedTo != null && !switchedTo.equals(newest) + && ClaudeSessionStore.sessionFileModified( + dir.toPath(), configDir, switchedTo) + > ClaudeSessionStore.sessionFileModified( + dir.toPath(), configDir, newest)) { + newest = switchedTo; + // A conversation another live tab holds is not + // this run's own file: it was resumed into, so + // adopting it must not establish ownership. + ownEvidence = false; + ownRecordMatch = false; + } + } } else if (!ownCandidates.isEmpty()) { newest = newestSharedSessionWrittenSince(dir, configDir, since, promptAt, ownCandidates, exclude); @@ -2420,23 +2457,26 @@ void completeGuiSend(java.util.function.LongSupplier clock) { boolean pendingExact = pendingGuiPromptExact; boolean pendingSet = pendingGuiPromptSet; resetTerminalInputRecording(); - if (alreadySubmitted && draftAfterEnter && !draftLine.isEmpty()) { - // The terminal Enter submitted the panel text during the pause, - // and the input typed afterwards is a new line that this Enter - // submits. The Enter cleared the recorder, so the recorded text - // is the whole line, and the submission happened after this - // Enter's time was sampled. + 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 - && pendingExact && promptSubmittedAt <= pendingAt) { + && promptSubmittedAt <= pendingAt) { // The draft typed after the terminal Enter was deleted again, - // so this Enter submits nothing: the panel text submitted by - // that Enter is still the exact, latest submission. A newer + // 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. rememberSentPrompt(pendingText, - guiSubmitAt != 0L ? guiSubmitAt : pendingAt, true); + guiSubmitAt != 0L ? guiSubmitAt : pendingAt, pendingExact); schedule = false; } else if (alreadySubmitted) { // The terminal's own Enter submitted the line before the diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 8d1bf281..c0d89798 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3363,6 +3363,35 @@ void anEarlyEnterPublishesTheUnknownTextOfACompletedDraft() { "the early Enter's time is the submission cutoff"); } + /** + * A GUI send extending a recalled draft is unknown-text evidence, and a + * terminal Enter during the pause submits that line. Up then recalls a + * second prompt: the recorder holds no text for that line either, yet the + * delayed Enter submits it. The submission must be recorded as unknown + * text at the delayed Enter, not dropped as if the Enter did nothing — + * otherwise the previous prompt stays the evidence with its old timestamp. + */ + @Test + void aRecalledDraftAfterTheEarlyEnterIsRecordedAsUnknownSubmission() { + controller.simulatePromptSubmittedAt(START_MS - 10_000L); + controller.simulatePromptText("earlier"); + + // Up recalls a prompt on the input line: only part of it is recorded. + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS); + controller.simulateGuiSend(" hello", START_MS + 10L); + // The terminal Enter submits the composed line; Up recalls another + // prompt on the new line, which leaves nothing in the recorder. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 50L); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 100L); + + controller.completeGuiSend(() -> START_MS + 250L); + + assertNull(controller.simulateLastPromptText(), + "a recalled line has no recorded text to match"); + assertEquals(START_MS + 250L, controller.simulatePromptSubmittedAtValue(), + "the delayed Enter submits the recalled line"); + } + /** * A terminal Enter during the pause submits the panel text; a draft typed * after it starts a new line, which the delayed Enter then submits. That @@ -3768,6 +3797,62 @@ void theFirstFilePriorityEndsAfterANewRunResolvedItsOwnFile() throws Exception { } } + /** + * A NEW tab resumes another live tab's conversation and only then submits + * a recalled prompt, before creating a file of its own. An exited tab's + * unrelated unclaimed record written after the submission must not be + * taken as this run's first file: the resumed conversation holds the + * actual submission and was written strictly later. + */ + @Test + void aNewTabResumingALiveConversationDoesNotAdoptAnExitedTabsRecord() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "c", "c"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("c.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + // Another live tab is writing conversation b. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "b", Set.of("b", "c")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS + 1_000L, true); + controller.simulateLiveSession(config, null, Set.of("b", "c")); + + try { + // The user resumes the live conversation b from the GUI. + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.simulateGuiChoiceAnswer("ARROW:1"); + + // A recalled prompt: its text is unknown. + controller.simulatePromptSubmittedAt(START_MS + 11_000L); + controller.simulatePromptText(null); + + // A third tab submits to c and exits: unclaimed, but unrelated. + appendUserPrompt(dir, "c", "other work", START_MS + 11_400L); + setMtime(dir.resolve("c.jsonl"), START_MS + 11_500L); + // The actual submission lands in the resumed conversation b, which + // is strictly newer. + appendUserPrompt(dir, "b", "recalled prompt", START_MS + 11_600L); + setMtime(dir.resolve("b.jsonl"), START_MS + 12_600L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the resumed conversation must win over an exited tab's record"); + assertEquals("b", controller.resolveActiveSessionId(), + "later scans must keep the resumed conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * The same race with known prompt text. Once the tab has borrowed a * sibling's conversation, the launch-known preference must not keep it From 58455a6a15be44aa7fe8d33078c6b9b7dba361bb Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 9 Nov 2026 20:21:16 +0300 Subject: [PATCH 077/111] Serialized prompt panel sends so a quick second send cannot replace the pending evidence of the first The prompt panel accepts another send while the previous one is waiting for its Enter. The second reconciliation then overwrote the pending text of a line still open in the TUI, so the Enter that submitted both texts together recorded only the second one as exact evidence. Sends now hold a lock from the text write through the Enter and the publication of its evidence, so each text is written on its own line and followed by its own Enter. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 63 ++++++++++++++----- .../ClaudeSessionControllerTest.java | 60 ++++++++++++++++++ 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index f8c0c4af..71d22f1d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -315,6 +315,15 @@ private static final class SessionClaims { */ private volatile long conversationSwitchAt; + /** + * 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(); + /** * 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 @@ -609,6 +618,21 @@ void simulateGuiSendWithText(String text, long sentAt) throws IOException { writeGuiPrompt(text, () -> sentAt); } + /** + * Package-private for tests: the body of {@link #sendPrompt(String)}'s + * sender thread — the serialized text write, pause and Enter — with a + * controllable clock and the real-time sleep replaced by {@code pause}. + */ + void simulateSendPrompt(String text, java.util.function.LongSupplier writeClock, + java.util.function.LongSupplier enterClock, Runnable pause) + throws IOException { + synchronized (guiSendLock) { + writeGuiPrompt(text, writeClock); + pause.run(); + completeGuiSend(enterClock); + } + } + /** Package-private for tests: the number of identity scans started. */ long simulateScanSequence() { synchronized (processOwnershipLock) { @@ -2263,23 +2287,30 @@ public void sendPrompt(String text) { } Thread t = new Thread(() -> { - 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); - Thread.sleep(200); - } catch (IOException ex) { - LOG.warning("sendPrompt write failed: " + ex.getMessage()); - resetTerminalInputRecording(); - return; - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - resetTerminalInputRecording(); - return; + // One send at a time: the panel accepts another send while the + // previous one is still waiting for its Enter, and its + // reconciliation would replace the pending evidence of a line + // that is still open in the TUI. + synchronized (guiSendLock) { + 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); + Thread.sleep(200); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + resetTerminalInputRecording(); + return; + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + resetTerminalInputRecording(); + return; + } + completeGuiSend(); } - completeGuiSend(); }, "pty-send-prompt"); t.setDaemon(true); t.start(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index c0d89798..7f7514f6 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import io.github.nbplugins.claudecodegui.model.ChoiceMenuModel; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -4242,6 +4243,65 @@ void terminalInputWaitsForTheGuiTextWrite() throws Exception { "terminal input must be processed after the write"); } + /** + * The prompt panel accepts a second send while the previous one is still + * in its pause. Writing the second text before the first Enter would put + * both prompts on one TUI line, which neither pending record describes, so + * the evidence would name only the second text. The second send must wait + * until the first line is submitted. + */ + @Test + void aSecondGuiSendWaitsForTheFirstSendToBeSubmitted() throws Exception { + List writes = Collections.synchronizedList(new ArrayList<>()); + controller.simulatePtyWrite(bytes -> writes.add( + new String(bytes, java.nio.charset.StandardCharsets.UTF_8))); + + AtomicReference failure = new AtomicReference<>(); + CountDownLatch firstPaused = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + Thread first = new Thread(() -> { + try { + controller.simulateSendPrompt("hello", () -> START_MS, + () -> START_MS + 200L, () -> { + firstPaused.countDown(); + try { + releaseFirst.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } catch (Throwable e) { + failure.set(e); + } + }, "first-gui-send"); + first.start(); + assertTrue(firstPaused.await(5, TimeUnit.SECONDS), + "the first text must be written"); + + CountDownLatch secondAtPause = new CountDownLatch(1); + Thread second = new Thread(() -> { + try { + controller.simulateSendPrompt("world", () -> START_MS + 50L, + () -> START_MS + 250L, secondAtPause::countDown); + } catch (Throwable e) { + failure.set(e); + } + }, "second-gui-send"); + second.start(); + assertFalse(secondAtPause.await(400, TimeUnit.MILLISECONDS), + "the second send must wait until the first line is submitted"); + + releaseFirst.countDown(); + first.join(5_000L); + second.join(5_000L); + + assertNull(failure.get(), "the sends must not fail"); + assertEquals(List.of("hello", "\r", "world", "\r"), writes, + "each text is written separately and followed by its own Enter"); + assertEquals("world", controller.simulateLastPromptText(), + "the last submission is the second prompt"); + } + /** * Text typed during the pause and then submitted by the terminal Enter is * only a part of the line (the panel text is not in the recorder), so the From 9f65ae94f6a1c3aaf944d0d3ee9b53083fed2eed Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 08:25:08 +0300 Subject: [PATCH 078/111] Kept a cancelled picker dismissed across GUI sends and marked a conversation switch only for the resume picker A choice menu cancelled with Esc immediately before a prompt panel send stayed retired only until the send reset the recorder: the send now keeps the dismissed-menu marker, so a prompt typed before the screen poll is no longer swallowed as another menu answer. Only the /resume session picker changes the conversation the process writes. Selecting an item in any other menu (the trust-folder confirmation, permission prompts, model pickers) no longer marks a conversation switch, neither for an Enter typed in the terminal nor for an ARROW answer from the choice panel, so the switch recovery cannot adopt an unrelated unheld record ahead of the launch conversation. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 94 ++++++++++--- .../ClaudeSessionControllerTest.java | 123 +++++++++++++++++- 2 files changed, 195 insertions(+), 22 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 71d22f1d..95ae6208 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -374,13 +374,22 @@ private static final class SessionClaims { /** * True when the last Enter confirmed a choice menu selection instead - * of submitting a prompt. The {@code /resume} picker changes the - * conversation that way, even when the command that opened it was recalled - * from the history or completed by Tab and is therefore not recognised as - * a command. + * 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 @@ -1847,6 +1856,23 @@ boolean scheduleIdentityRefresh() { * 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 and GUI menu answers + * clear it + */ + private void resetTerminalInputRecording(boolean keepCompletedMenu) { synchronized (terminalInputLine) { terminalInputLine.setLength(0); terminalInputCursor = 0; @@ -1856,6 +1882,7 @@ private void resetTerminalInputRecording() { terminalPasteAfterCR = false; terminalEscapeOnMenu = false; terminalMenuSelection = false; + terminalSelectedMenu = null; terminalLineEntered = false; terminalEnterOnEmptyLine = false; terminalHistoryRecall = false; @@ -1870,7 +1897,9 @@ private void resetTerminalInputRecording() { pendingGuiPromptAt = 0L; pendingGuiPromptExact = false; pendingGuiPromptSet = false; - terminalCompletedMenu = null; + if (!keepCompletedMenu) { + terminalCompletedMenu = null; + } } } @@ -2302,11 +2331,11 @@ public void sendPrompt(String text) { Thread.sleep(200); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); - resetTerminalInputRecording(); + resetTerminalInputRecording(true); return; } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - resetTerminalInputRecording(); + resetTerminalInputRecording(true); return; } completeGuiSend(); @@ -2382,7 +2411,9 @@ private String reconcileGuiSend(String text, long sentAt) { // 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. - resetTerminalInputRecording(); + // 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 @@ -2474,7 +2505,7 @@ void completeGuiSend(java.util.function.LongSupplier clock) { writePty("\r"); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); - resetTerminalInputRecording(); + resetTerminalInputRecording(true); return; } boolean edited = terminalInputSinceGuiSend; @@ -2487,7 +2518,7 @@ void completeGuiSend(java.util.function.LongSupplier clock) { long pendingAt = pendingGuiPromptAt; boolean pendingExact = pendingGuiPromptExact; boolean pendingSet = pendingGuiPromptSet; - resetTerminalInputRecording(); + resetTerminalInputRecording(true); if (alreadySubmitted && draftAfterEnter && (!draftLine.isEmpty() || !draftExact)) { // A new line was started after the terminal Enter that @@ -2605,16 +2636,36 @@ private static boolean isConversationCommand(String text) { return command.startsWith("/resume") || command.startsWith("/clear"); } + /** + * 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 menu menu the input is answering, or {@code null} + */ + 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"); + } + /** * Records that the GUI answered a choice menu. An {@code ARROW:n} answer - * selects an entry in an unnumbered picker (e.g. the {@code /resume} - * session list), which changes the conversation the process writes; the - * write is made by the plugin, so it leaves the terminal-input time alone. + * 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:")) { + if (answer != null && answer.startsWith("ARROW:") + && isConversationPicker(model.getActiveChoiceMenu())) { conversationSwitchAt = System.currentTimeMillis(); } } @@ -4075,6 +4126,7 @@ private String onTerminalInput(byte[] bytes, long now) { boolean emptyEnter; boolean recalled; boolean menuSelection; + ChoiceMenuModel selectedMenu; synchronized (terminalInputLine) { terminalLineEntered = false; terminalEnterOnEmptyLine = false; @@ -4087,6 +4139,8 @@ private String onTerminalInput(byte[] bytes, long now) { terminalRecalledSubmission = false; menuSelection = terminalMenuSelection; terminalMenuSelection = false; + selectedMenu = terminalSelectedMenu; + terminalSelectedMenu = null; if (entered && !menuSelection && guiSendAwaitingEnter && terminalGuiSubmitAt == 0L) { // An Enter during the GUI send's pause submits the line the @@ -4102,10 +4156,13 @@ private String onTerminalInput(byte[] bytes, long now) { if (entered && !emptyEnter) { externalInputAt = now; } - if (menuSelection) { - // Enter with a choice menu open selected an item. The /resume - // picker changes the conversation that way, and nothing else in the - // input tells which conversation was picked. + if (menuSelection && isConversationPicker(selectedMenu)) { + // 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) { @@ -4295,6 +4352,7 @@ private String appendTerminalInput(byte[] bytes) { 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), diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7f7514f6..bd5b7cf2 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4690,7 +4690,7 @@ void aRecalledResumeCommandFollowsTheUnclaimedDestination() throws Exception { 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", List.of( + 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); @@ -4724,11 +4724,126 @@ void aRecalledResumeCommandFollowsTheUnclaimedDestination() throws Exception { } /** - * A provisional conversation borrowed from a NEW sibling is not this tab's - * own: preferring it would hide the launch file that actually holds the - * prompt, even though that file is strictly newer. + * 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 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"); + } + @Test void aBorrowedActiveConversationIsNotPreferred() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); java.nio.file.Path work = tmpDir.resolve("project"); From a5c29a37d72a68395ce61d65117940669b544968 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 08:44:55 +0300 Subject: [PATCH 079/111] Cancelled stale panel sends across a restart and kept text typed after an early terminal Enter A panel send now carries the process generation it was accepted for and checks it under guiSendLock before the text write and again before the Enter, so a send queued behind another one cannot deliver its prompt (or a stale Enter) to the replacement process after a tab restart. The recorder is only reset when the generation is still current. After a terminal Enter submits the panel text during the send's pause, the first submission is tracked separately (not a menu selection, no Enter yet), so a line typed and submitted afterwards is recorded in full instead of being reported as a part of the panel line; the delayed completeGuiSend no longer clears or re-times that newer exact evidence. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 74 ++++++++++++++- .../ClaudeSessionControllerTest.java | 93 +++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 95ae6208..07972b90 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -635,9 +635,16 @@ void simulateGuiSendWithText(String text, long sentAt) throws IOException { void simulateSendPrompt(String text, java.util.function.LongSupplier writeClock, java.util.function.LongSupplier enterClock, Runnable pause) throws IOException { + long sendGeneration = currentProcessGeneration(); synchronized (guiSendLock) { + if (!isProcessGenerationCurrent(sendGeneration)) { + return; + } writeGuiPrompt(text, writeClock); pause.run(); + if (!isProcessGenerationCurrent(sendGeneration)) { + return; + } completeGuiSend(enterClock); } } @@ -2275,11 +2282,27 @@ void simulateProcessExit() { /** Package-private for tests: the ownership generation of the current run. */ long simulateProcessGeneration() { + return currentProcessGeneration(); + } + + /** The ownership generation of the run that is live now. */ + private long currentProcessGeneration() { synchronized (processOwnershipLock) { return processGeneration; } } + /** + * Whether {@code generation} is still the live run. A panel send carries + * the generation it was accepted for, so a restart in the meantime cancels + * its remaining writes instead of delivering them to the new process. + */ + private boolean isProcessGenerationCurrent(long generation) { + synchronized (processOwnershipLock) { + return generation == processGeneration; + } + } + /** Package-private for tests: replays the exit callback of an older run. */ void simulateProcessExitAt(long generation) { onProcessExited(generation); @@ -2315,12 +2338,21 @@ public void sendPrompt(String text) { 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. + long sendGeneration = currentProcessGeneration(); Thread t = new Thread(() -> { // One send at a time: the panel accepts another send while the // previous one is still waiting for its Enter, and its // reconciliation would replace the pending evidence of a line // that is still open in the TUI. synchronized (guiSendLock) { + 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 @@ -2331,11 +2363,24 @@ public void sendPrompt(String text) { Thread.sleep(200); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); - resetTerminalInputRecording(true); + if (isProcessGenerationCurrent(sendGeneration)) { + resetTerminalInputRecording(true); + } return; } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - resetTerminalInputRecording(true); + if (isProcessGenerationCurrent(sendGeneration)) { + resetTerminalInputRecording(true); + } + 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(); @@ -2553,7 +2598,17 @@ void completeGuiSend(java.util.function.LongSupplier clock) { // reconciliation reset the recall/completion flags, so the // line it saw was empty — the pending submission is published // here instead, unknown-text or not. - if (edited) { + 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, @@ -4333,12 +4388,19 @@ private String appendTerminalInput(byte[] bytes) { 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 = guiSendAwaitingEnter && terminalInputSinceGuiSend; + boolean gapEdited = firstSubmissionAfterGuiSend && terminalInputSinceGuiSend; terminalEnterSinceGuiSend = true; terminalHistoryRecall = false; terminalCompletionEdited = false; @@ -4371,7 +4433,7 @@ private String appendTerminalInput(byte[] bytes) { // 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 (guiSendAwaitingEnter) { + if (firstSubmissionAfterGuiSend) { // 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 @@ -4380,6 +4442,8 @@ private String appendTerminalInput(byte[] bytes) { // the partial text before the panel's Enter is sent. terminalRecalledSubmission = true; } else { + // Anything typed after that submission starts a new + // line the recorder has in full, so it is known text. submitted = line; } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index bd5b7cf2..8cdb105c 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4302,6 +4302,73 @@ void aSecondGuiSendWaitsForTheFirstSendToBeSubmitted() throws Exception { "the last submission is the second prompt"); } + /** + * A send queued behind another one belongs to the run that accepted it. If + * the tab is restarted while it waits, it must not write the prompt through + * the replacement process's connector, where it would execute in a + * conversation the user did not type it for. Once the first send's text + * went to the closed process, its delayed Enter must not be delivered to + * the replacement either. + */ + @Test + void aQueuedGuiSendIsDroppedWhenTheTabRestartsBeforeItWrites() throws Exception { + List writes = Collections.synchronizedList(new ArrayList<>()); + controller.simulatePtyWrite(bytes -> writes.add( + new String(bytes, java.nio.charset.StandardCharsets.UTF_8))); + + AtomicReference failure = new AtomicReference<>(); + CountDownLatch firstPaused = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + Thread first = new Thread(() -> { + try { + controller.simulateSendPrompt("hello", () -> START_MS, + () -> START_MS + 200L, () -> { + firstPaused.countDown(); + try { + releaseFirst.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } catch (Throwable e) { + failure.set(e); + } + }, "first-gui-send"); + first.start(); + assertTrue(firstPaused.await(5, TimeUnit.SECONDS), + "the first text must be written"); + + Thread second = new Thread(() -> { + try { + controller.simulateSendPrompt("world", () -> START_MS + 50L, + () -> START_MS + 250L, () -> { }); + } catch (Throwable e) { + failure.set(e); + } + }, "second-gui-send"); + second.start(); + // BLOCKED means the sender has already captured the generation it was + // accepted for and is waiting for the first send to release the lock. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (second.getState() != Thread.State.BLOCKED + && System.nanoTime() < deadline) { + Thread.sleep(10L); + } + assertEquals(Thread.State.BLOCKED, second.getState(), + "the second send must be queued behind the first one"); + + // The user stops the session and starts another one while both sends + // are still open. + controller.stopProcess(); + releaseFirst.countDown(); + first.join(5_000L); + second.join(5_000L); + + assertNull(failure.get(), "the sends must not fail"); + assertEquals(List.of("hello"), writes, + "neither the queued prompt nor a stale Enter may reach the replacement process"); + } + /** * Text typed during the pause and then submitted by the terminal Enter is * only a part of the line (the panel text is not in the recorder), so the @@ -4323,6 +4390,32 @@ void terminalTextSubmittedByTheGapEnterKeepsItsOwnTime() { "the terminal Enter's time is the submission time"); } + /** + * Once a terminal Enter has submitted the panel line during the pause, the + * next line typed in the terminal is recorded in full: its submission must + * keep its text instead of being reported as a part of the panel line, and + * the panel's delayed Enter must not clear that evidence either. + */ + @Test + void textSubmittedAfterTheGapEnterStaysKnown() { + controller.simulateGuiSend("hello", START_MS); + // The user submits the panel line from the terminal before the + // panel's own Enter is written. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 50L); + + assertEquals("next", controller.simulateTerminalInput( + "next\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the line typed on the fresh line is recorded in full"); + + controller.completeGuiSend(() -> START_MS + 200L); + + assertEquals("next", controller.simulateLastPromptText(), + "the panel's Enter must not clear the newer submission's text"); + assertEquals(START_MS + 100L, controller.simulatePromptSubmittedAtValue(), + "the newer submission keeps its own time"); + } + /** Clearing the input with Ctrl+U discards a recalled prompt: the next text is known. */ @Test void clearingARecalledPromptMakesTheNextSubmissionKnown() { From a2f280a9da691b9cdd58badd80dd0240578fe521 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 09:09:54 +0300 Subject: [PATCH 080/111] Retired menus answered by a panel send and kept the known own session outside the candidate cap A panel send with a choice menu open (the menu can be hidden while the panel stays usable) now writes its Enter against that menu: it does not publish the picker's search filter as prompt evidence, it retires the stale model entry so the next prompt is not swallowed as another menu answer, and it records a conversation switch when the menu is the /resume picker. A menu answered from the choice panel is retired the same way, and a terminal Enter that answers the menu during the pause marks the pending panel text as consumed instead of publishing it. The prompt-evidence search now always considers an eligible preferred session even when newer unrelated files fill the eight-candidate list, so a file carrying the same common prompt text can no longer take over the resolved own conversation. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 72 +++++++++++++- .../ClaudeSessionControllerTest.java | 97 +++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 07972b90..e6e46d18 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -462,6 +462,14 @@ private static final class SessionClaims { */ 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; + /** * 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 @@ -1655,6 +1663,16 @@ private String findPromptBearingSession(java.io.File dir, java.nio.file.Path con ClaudeSessionStore.listSessionIdsWrittenSince( dir.toPath(), configDir, mtimeCutoff, exclude, PROMPT_EVIDENCE_CANDIDATES)); + // The known own conversation is checked even when newer unrelated + // files fill the limited discovery list: it is still eligible, and + // dropping it would let a file carrying the same common prompt text + // take over the tab's own identity. Eligibility — not excluded and + // written since the cutoff — is the same as for the listed files. + if (preferred != null && (exclude == null || !exclude.contains(preferred)) + && ClaudeSessionStore.sessionFileModified( + dir.toPath(), configDir, preferred) >= mtimeCutoff) { + candidates.add(preferred); + } return findPromptBearingSession(dir, configDir, candidates, preferred, promptAt, promptText); } @@ -1876,8 +1894,7 @@ private void resetTerminalInputRecording() { * before the screen poll as another menu answer and swallows it. * * @param keepCompletedMenu {@code true} to preserve the dismissed-menu - * marker; process resets and GUI menu answers - * clear it + * marker; process resets clear it */ private void resetTerminalInputRecording(boolean keepCompletedMenu) { synchronized (terminalInputLine) { @@ -1900,6 +1917,7 @@ private void resetTerminalInputRecording(boolean keepCompletedMenu) { terminalDraftAfterEnter = false; terminalGuiSubmitAt = 0L; guiSendAwaitingEnter = false; + terminalMenuConsumedGuiSend = false; pendingGuiPromptText = null; pendingGuiPromptAt = 0L; pendingGuiPromptExact = false; @@ -2546,6 +2564,39 @@ void completeGuiSend(java.util.function.LongSupplier clock) { // bytes, and a record written then must still pass the submission // cutoff used by the evidence lookup. long submittedAt = clock.getAsLong(); + ChoiceMenuModel openMenu = openChoiceMenu(); + if (openMenu != null || terminalMenuConsumedGuiSend) { + // 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"); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + resetTerminalInputRecording(true); + return; + } + if (openMenu != null && !isCheckboxMenu(openMenu)) { + // A numbered picker closes on its selection Enter (a + // checkbox menu only toggles and stays open until Right). + completeTerminalMenu(openMenu); + } + if (openMenu != null && 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. When a + // terminal Enter answered the picker during the pause, + // it already recorded the switch. + conversationSwitchAt = submittedAt; + } + resetTerminalInputRecording(true); + return; + } try { writePty("\r"); } catch (IOException ex) { @@ -2774,8 +2825,14 @@ public void triggerPromptScan() { 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. - resetTerminalInputRecording(); + // 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. + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } + resetTerminalInputRecording(true); try { if (answer == null) { LOG.fine("[PTY write] \\x1b (Cancel/ESC)"); @@ -4196,6 +4253,13 @@ private String onTerminalInput(byte[] bytes, long now) { terminalMenuSelection = false; selectedMenu = terminalSelectedMenu; terminalSelectedMenu = null; + 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 8cdb105c..b11c7633 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4936,6 +4936,103 @@ void aPickerCancelledBeforeAGuiSendStaysDismissed() { 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"); + } + + /** + * 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"); + } @Test void aBorrowedActiveConversationIsNotPreferred() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From 419f62de29f758d0d37cf0116f9b70399bd988d6 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 09:25:11 +0300 Subject: [PATCH 081/111] Recorded the draft submitted after an early picker selection and applied the mtime slack to the known own session When a terminal Enter answered the choice menu during a panel send's pause and the user typed the next prompt before the delayed Enter, the consumed-menu branch discarded that draft. It now records the draft the Enter actually submits; only the menu-bound panel text is dropped. The prompt-evidence lookup checks the known own conversation with the relaxed cutoff before accepting a nonpreferred match, so a rounded mtime just below the cutoff cannot hand the identity to a lookalike file that merely carries the same common prompt. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 73 +++++++++++++++++-- .../ClaudeSessionControllerTest.java | 71 ++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index e6e46d18..ec3f6d01 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1336,6 +1336,23 @@ boolean refreshSessionIdentity() { } newest = findPromptBearingSession(dir, configDir, since, ownExclude, ownPreferred, promptAt, promptText); + if (ownPreferred != null && !ownPreferred.equals(newest) + && since > SESSION_MTIME_GRANULARITY_MS) { + // Coarse file times can round this tab's own file below the + // cutoff even though it already holds the prompt, while an + // unrelated conversation records the same common text in + // the next timestamp bucket. The own file is stronger + // evidence, so the slack is applied to it before the + // lookalike match is accepted: otherwise the lookalike is + // published as this run's own file and later scans keep + // preferring it. + String relaxedOwn = promptBearingPreferred(dir, configDir, + since - SESSION_MTIME_GRANULARITY_MS, ownExclude, + ownPreferred, promptAt, promptText); + if (relaxedOwn != null) { + newest = relaxedOwn; + } + } if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { // Coarse file times can round this tab's own file below the // cutoff even though it already holds the prompt. Its record @@ -1656,6 +1673,29 @@ boolean refreshSessionIdentity() { * * @param preferred session ID to check first; may be {@code null} */ + /** + * Returns {@code preferred} when it is eligible and already holds the + * submitted prompt, or {@code null}. + * + *

    The known own conversation is checked with the same eligibility as + * the discovery candidates — not excluded and written at or after the + * cutoff — but independently of their limited list and of any lookalike + * match found there, so a file merely carrying the same common text + * cannot take over its identity. + */ + private String promptBearingPreferred(java.io.File dir, java.nio.file.Path configDir, + long mtimeCutoff, Set exclude, + String preferred, long promptAt, String promptText) { + if (preferred == null || promptText == null || promptText.isBlank()) return null; + if (exclude != null && exclude.contains(preferred)) return null; + if (ClaudeSessionStore.sessionFileModified(dir.toPath(), configDir, preferred) + < mtimeCutoff) { + return null; + } + return containsPrompt(dir, configDir, preferred, promptAt, promptText) + ? preferred : null; + } + private String findPromptBearingSession(java.io.File dir, java.nio.file.Path configDir, long mtimeCutoff, Set exclude, String preferred, long promptAt, String promptText) { @@ -2565,7 +2605,7 @@ void completeGuiSend(java.util.function.LongSupplier clock) { // cutoff used by the evidence lookup. long submittedAt = clock.getAsLong(); ChoiceMenuModel openMenu = openChoiceMenu(); - if (openMenu != null || terminalMenuConsumedGuiSend) { + 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 @@ -2581,22 +2621,43 @@ void completeGuiSend(java.util.function.LongSupplier clock) { resetTerminalInputRecording(true); return; } - if (openMenu != null && !isCheckboxMenu(openMenu)) { + if (!isCheckboxMenu(openMenu)) { // A numbered picker closes on its selection Enter (a // checkbox menu only toggles and stays open until Right). completeTerminalMenu(openMenu); } - if (openMenu != null && isConversationPicker(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. When a - // terminal Enter answered the picker during the pause, - // it already recorded the switch. + // the next submission has to follow the switch. conversationSwitchAt = submittedAt; } resetTerminalInputRecording(true); return; } + if (terminalMenuConsumedGuiSend) { + // A terminal Enter already answered the choice menu this + // panel text was typed into, so 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. + String draftLine = terminalInputLine.toString().trim(); + boolean draftExact = !terminalHistoryRecall && !terminalCompletionEdited; + boolean draftAfterSelection = terminalDraftAfterEnter; + try { + writePty("\r"); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + resetTerminalInputRecording(true); + return; + } + resetTerminalInputRecording(true); + if (draftAfterSelection && (!draftLine.isEmpty() || !draftExact)) { + rememberSentPrompt(draftLine, submittedAt, draftExact); + } + return; + } try { writePty("\r"); } catch (IOException ex) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index b11c7633..1f820624 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4984,6 +4984,33 @@ void aPanelSendAfterThePanelCancelledTheMenuIsAPrompt() { "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"); + } + /** * 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 @@ -5033,6 +5060,50 @@ void theResolvedOwnSessionSurvivesTheCandidateCap() throws Exception { 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"); From 8338f0f9305c980d08328f2421541ea683e72119 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 09:48:09 +0300 Subject: [PATCH 082/111] Cleared the text typed into a numbered menu and consumed a picker cancellation before the delayed GUI Enter A digit selecting an item of a numbered menu closes it immediately, but only the dismissed-menu marker was set: the search text accumulated while the menu was open stayed in the recorder and prefixed the next prompt, so the evidence published for it was text the conversation never received. The digit now discards the menu's text, as the Right-submit path already does for a checkbox menu. completeGuiSend classified its Enter against the model's menu entry without consuming a standalone Esc that was still pending from the pause: the cancellation typed by the user left terminalInputEscape == 1 with terminalEscapeOnMenu set, so the Enter acting on the empty main prompt was taken for a /resume picker selection and marked a conversation switch. The pending cancellation is now consumed before the menu lookup, so the Enter is not recorded as a picker selection. Both fixes keep the recorder's prompt evidence aligned with what the conversation actually received, which the identity scan matches against the session files. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 36 ++++++--- .../ClaudeSessionControllerTest.java | 77 +++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index ec3f6d01..9b6cf95c 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1969,9 +1969,10 @@ private void resetTerminalInputRecording(boolean keepCompletedMenu) { } /** - * Discards the recorded input after a standalone {@code ESC} cancelled a - * choice menu: the line then holds the picker's search text, not a prompt - * draft. Call while holding the {@link #terminalInputLine} lock. + * 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); @@ -2604,6 +2605,17 @@ void completeGuiSend(java.util.function.LongSupplier clock) { // 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) { + discardPickerSearchText(); + terminalInputEscape = 0; + } ChoiceMenuModel openMenu = openChoiceMenu(); if (openMenu != null) { // A choice menu is open on the screen (with the menu hidden, @@ -2635,13 +2647,13 @@ void completeGuiSend(java.util.function.LongSupplier clock) { resetTerminalInputRecording(true); return; } - if (terminalMenuConsumedGuiSend) { + if (terminalMenuConsumedGuiSend || cancelledPicker) { // A terminal Enter already answered the choice menu this - // panel text was typed into, so 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. + // panel text was typed into, or a terminal Esc cancelled it, + // so 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. String draftLine = terminalInputLine.toString().trim(); boolean draftExact = !terminalHistoryRecall && !terminalCompletionEdited; boolean draftAfterSelection = terminalDraftAfterEnter; @@ -4599,7 +4611,11 @@ private String appendTerminalInput(byte[] bytes) { ChoiceMenuModel openMenu = openChoiceMenu(); if (openMenu != null && c >= '0' && c <= '9') { if (digitSelectionDismissesMenu(openMenu)) { - completeTerminalMenu(openMenu); + // 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 { insertTerminalChar(c); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 1f820624..7b1d25b1 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -5011,6 +5011,83 @@ void aDraftTypedAfterThePickerSelectionIsRecorded() { "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"); + } + /** * 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 From 07c5c2b7987d3668a6d3db6109db59bae0de9c8e Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 10:14:08 +0300 Subject: [PATCH 083/111] Kept the launch conversation when a stopping tab only borrowed it and treated label-typed text options as text input A tab that resumed a saved conversation another live tab had already claimed only borrows that file, and claimedSessionIds() dropped borrowed files so that a guessed sibling file could not win the final post-stop lookup. But the launch identity of a --continue/--resume tab is not a guess: it is the conversation the tab was opened on. When the exit waiter released the borrowed hold during stopAndRename(), the launch file became excluded as the other tab's file and an unrelated unheld conversation written since the launch took the rename. claimedSessionIds() now keeps the launch identity even when it is borrowed; a merely guessed borrowed file stays excluded. digitSelectionDismissesMenu() only recognised a free-text option by its hasTextInput flag, while the answer protocol also knows options labelled "Type something." (isTextInputOption). Through such an option the digit only activates its editor and the menu stays open, so retiring the menu turned the answer typed next into exact prompt evidence. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 27 ++++++- .../ClaudeSessionControllerTest.java | 80 +++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 9b6cf95c..eb76ec55 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -2029,7 +2029,9 @@ private static boolean isCheckboxMenu(ChoiceMenuModel menu) { * list) uses digits as search text and stays open, and a checkbox or * text-input menu only moves to an option on a digit, waiting for its * submit key — retiring those on the first digit would turn the search - * text into prompt evidence. + * 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. */ private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu) { if (menu.options().isEmpty()) return false; @@ -2040,7 +2042,7 @@ private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu) { char c = response.charAt(i); if (c < '0' || c > '9') return false; } - if (option.hasCheckbox() || option.hasTextInput()) return false; + if (option.hasCheckbox() || isTextInputOption(option)) return false; } return true; } @@ -2231,7 +2233,11 @@ private Set sessionsOwnedByOtherLiveTabs(java.io.File dir, *

    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. + * post-stop lookup over the file this tab actually created. The launch + * conversation is the exception: a tab that resumed a saved file another + * live tab had already claimed only borrows it, yet that file is still the + * conversation this tab was opened on, and its rename must target it — a + * merely guessed borrowed file stays excluded. */ Set claimedSessionIds(java.io.File dir, java.nio.file.Path configDir) { Set ids = new HashSet<>(); @@ -2242,13 +2248,26 @@ Set claimedSessionIds(java.io.File dir, java.nio.file.Path configDir) { if (key.configDir().equals(wantedConfig) && key.projectDir().equals(wantedProject) && claims.holders.contains(this) - && !claims.borrowed.contains(this)) { + && (!claims.borrowed.contains(this) + || isLaunchIdentity(key.sessionId()))) { ids.add(key.sessionId()); } }); return ids; } + /** + * Returns whether {@code sessionId} is the conversation this tab launched + * on with {@code --continue}/{@code --resume}. Such a conversation is this + * tab's own even when another live tab claimed it first and this tab is + * therefore only a borrower: the file is not a guess made from matching + * prompt text. + */ + private boolean isLaunchIdentity(String sessionId) { + return launchIdentityKnown && launchSessionId != null + && launchSessionId.equals(sessionId); + } + /** * Session IDs claimed by other live controllers in the same store. * A session ID is not unique across config dirs (a JSONL can be copied diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7b1d25b1..5b6843da 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2900,6 +2900,64 @@ void theFinalStopLookupKeepsTheConversationsTheTabHeld() throws Exception { } } + /** + * Two tabs resumed the same saved conversation {@code a}: the first owns + * it, so the second only borrows it even though {@code --resume} named that + * file. Stopping the second tab without submitting a prompt releases the + * borrowed hold, and the final lookup must still keep the launch + * conversation — an unrelated conversation written since the launch must + * not take the rename just because it is unheld. + */ + @Test + void aStoppingTabKeepsTheLaunchConversationItOnlyBorrows() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + // The first tab owns the saved conversation. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // The second tab resumed the same conversation and so only borrows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList) { + @Override + void stopWriter() { + super.stopWriter(); + // The exit waiter releases this tab's claims while the + // writer shuts down, before the final lookup runs. + simulateProcessExit(); + } + }; + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // An unrelated conversation is written after the launch but stays + // older than the launch conversation. + appendUserPrompt(dir, "noise", "unrelated work", START_MS + 10_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 11_000L); + appendUserPrompt(dir, "a", "from the first tab", START_MS + 11_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_000L); + + tabB.stopAndRename("Shared Name"); + + assertEquals("Shared Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation is renamed, not a newer unrelated file"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "noise").customTitle(), + "the unrelated conversation must keep its name"); + } + /** * A NEW tab temporarily borrows a live sibling's conversation because both * submitted the same text before its own file was flushed. The borrowed @@ -4539,6 +4597,28 @@ void aMenuSelectionDigitIsNotRecordedAsPromptDraft() { "the menu selection must not prefix the submitted prompt"); } + /** + * 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"); + } + /** * 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 From 837dfe894d860cfde456a8e2c8513a8299b3f3bb Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 10:37:57 +0300 Subject: [PATCH 084/111] Delivered panel sends through an ordered queue and classified menu digits option by option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendPrompt() started a thread per accepted send and let the threads compete for the send monitor. A monitor has no first-come-first-served order, so two sends already waiting for the first one's 200 ms pause could be written swapped — a slash command and the instruction it was meant to precede would then execute in the wrong order. The send body moved to runGuiSend() and each accepted send is submitted to a single-thread executor, which delivers the jobs in acceptance order. stopProcess() interrupts the worker and drops the queue; a send accepted for a run that no longer exists is still dropped by its generation check. The pause between the text and its Enter is an overridable method so a test can hold a send open. digitSelectionDismissesMenu() declared a numbered menu non-dismissing when *any* option offered a text editor, so in a menu mixing ordinary choices with "Type something." selecting ordinary 1 left the menu recorded as open and the following prompt was swallowed as another menu answer. The digit's own option now decides: only a checkbox or text-input option selected by that digit keeps the menu open. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 188 +++++++++++++----- .../ClaudeSessionControllerTest.java | 72 +++++++ 2 files changed, 210 insertions(+), 50 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index eb76ec55..57260c9f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -324,6 +324,20 @@ private static final class SessionClaims { */ 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 @@ -657,6 +671,14 @@ void simulateSendPrompt(String text, java.util.function.LongSupplier writeClock, } } + /** + * Package-private for tests: submits a send through the ordered queue, as + * {@link #sendPrompt(String)} does, and returns the queued task. + */ + java.util.concurrent.Future simulateQueueGuiSend(String text) { + return queueGuiSend(text, currentProcessGeneration()); + } + /** Package-private for tests: the number of identity scans started. */ long simulateScanSequence() { synchronized (processOwnershipLock) { @@ -1022,6 +1044,7 @@ void stopWriter() { } public void stopProcess() { + shutdownGuiSendQueue(); stopWriter(); ClaudeProcess processToStop = claudeProcess; if (processToStop != null) { @@ -2021,19 +2044,23 @@ private static boolean isCheckboxMenu(ChoiceMenuModel menu) { } /** - * Returns whether a digit typed while {@code menu} is open selects an item - * and dismisses the menu. + * 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 menu only moves to an option on a digit, waiting for its - * submit key — retiring those on the first digit would turn the search + * 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. */ - private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu) { + private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu, char digit) { if (menu.options().isEmpty()) return false; for (ChoiceMenuModel.Option option : menu.options()) { String response = option.response(); @@ -2042,7 +2069,11 @@ private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu) { char c = response.charAt(i); if (c < '0' || c > '9') return false; } - if (option.hasCheckbox() || isTextInputOption(option)) return false; + if (response.length() == 1 && response.charAt(0) == digit + && (option.hasCheckbox() || isTextInputOption(option))) { + // The digit only activates this option's editor or toggles it. + return false; + } } return true; } @@ -2420,52 +2451,109 @@ public void sendPrompt(String text) { // 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. - long sendGeneration = currentProcessGeneration(); - Thread t = new Thread(() -> { - // One send at a time: the panel accepts another send while the - // previous one is still waiting for its Enter, and its - // reconciliation would replace the pending evidence of a line - // that is still open in the TUI. - synchronized (guiSendLock) { - 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); - Thread.sleep(200); - } catch (IOException ex) { - LOG.warning("sendPrompt write failed: " + ex.getMessage()); - if (isProcessGenerationCurrent(sendGeneration)) { - resetTerminalInputRecording(true); - } - return; - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - if (isProcessGenerationCurrent(sendGeneration)) { - resetTerminalInputRecording(true); - } - return; + 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) { + 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)); + } 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}. + */ + private void runGuiSend(String text, long sendGeneration) { + synchronized (guiSendLock) { + 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); + pauseGuiSend(); + } catch (IOException ex) { + LOG.warning("sendPrompt write failed: " + ex.getMessage()); + if (isProcessGenerationCurrent(sendGeneration)) { + resetTerminalInputRecording(true); } - 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; + 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. + if (isProcessGenerationCurrent(sendGeneration)) { + resetTerminalInputRecording(true); } - completeGuiSend(); + return; } - }, "pty-send-prompt"); - t.setDaemon(true); - t.start(); + 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(); + } + } + + /** + * 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); } /** {@link #writeGuiPrompt(String, LongSupplier)} with the wall clock. */ @@ -4629,7 +4717,7 @@ private String appendTerminalInput(byte[] bytes) { // with the selection and make the actual text unmatchable. ChoiceMenuModel openMenu = openChoiceMenu(); if (openMenu != null && c >= '0' && c <= '9') { - if (digitSelectionDismissesMenu(openMenu)) { + 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 5b6843da..b5805ba8 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4427,6 +4427,56 @@ void aQueuedGuiSendIsDroppedWhenTheTabRestartsBeforeItWrites() throws Exception "neither the queued prompt nor a stale Enter may reach the replacement process"); } + /** + * Three sends accepted in quick succession must reach the TUI in that + * order. Giving each send its own thread that competes for the send lock + * offers no FIFO guarantee — a monitor may hand the lock to either waiter + * — so the prompts could be written swapped, and a slash command and the + * instruction it was meant to precede could run in the wrong order. + */ + @Test + void queuedGuiSendsAreDeliveredInAcceptanceOrder() throws Exception { + List writes = Collections.synchronizedList(new ArrayList<>()); + java.util.Set sendThreads = java.util.Collections.synchronizedSet( + java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap())); + + CountDownLatch firstPaused = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicBoolean first = + new java.util.concurrent.atomic.AtomicBoolean(true); + controller = new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() throws InterruptedException { + if (first.compareAndSet(true, false)) { + firstPaused.countDown(); + releaseFirst.await(5, TimeUnit.SECONDS); + } + } + }; + controller.simulatePtyWrite(bytes -> { + writes.add(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + sendThreads.add(Thread.currentThread()); + }); + + java.util.concurrent.Future firstSend = controller.simulateQueueGuiSend("first"); + java.util.concurrent.Future secondSend = controller.simulateQueueGuiSend("second"); + java.util.concurrent.Future thirdSend = controller.simulateQueueGuiSend("third"); + + assertTrue(firstPaused.await(5, TimeUnit.SECONDS), + "the first text must be written"); + releaseFirst.countDown(); + firstSend.get(5, TimeUnit.SECONDS); + secondSend.get(5, TimeUnit.SECONDS); + thirdSend.get(5, TimeUnit.SECONDS); + + assertEquals(List.of("first", "\r", "second", "\r", "third", "\r"), + new ArrayList<>(writes), + "the prompts must be submitted in the order they were accepted"); + assertEquals(1, sendThreads.size(), + "the sends must be delivered by one ordered worker, not competing threads"); + } + /** * Text typed during the pause and then submitted by the terminal Enter is * only a part of the line (the panel text is not in the recorder), so the @@ -4619,6 +4669,28 @@ void aDigitOnALabelTypedTextOptionDoesNotDismissTheMenu() { "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 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 From 9e10862611fe6911daea86466ef9d296ad765a68 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 10:56:48 +0300 Subject: [PATCH 085/111] Kept a numbered menu open when the typed digit did not name an option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit digitSelectionDismissesMenu() returned true for any digit once the menu's options were all numbers, so a stray 9 on a menu offering only 1 and 2 retired the still-open menu: the following 1 was recorded as line text instead of a selection, and the next prompt was published as "1hello" — exact evidence that matches no file the conversation wrote. The digit now retires the menu only when it actually matches an ordinary option. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 10 +++++--- .../ClaudeSessionControllerTest.java | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 57260c9f..c7f42670 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -2059,9 +2059,12 @@ private static boolean isCheckboxMenu(ChoiceMenuModel menu) { *

    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. */ private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu, char digit) { if (menu.options().isEmpty()) return false; + boolean matched = false; for (ChoiceMenuModel.Option option : menu.options()) { String response = option.response(); if (response == null || response.isEmpty()) return false; @@ -2069,13 +2072,14 @@ private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu, char di char c = response.charAt(i); if (c < '0' || c > '9') return false; } - if (response.length() == 1 && response.charAt(0) == digit - && (option.hasCheckbox() || isTextInputOption(option))) { + 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 true; + return matched; } /** Package-private for tests: the PTY is no longer running (Claude exited). */ diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index b5805ba8..04fccdb4 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -4691,6 +4691,29 @@ void anOrdinaryChoiceInAMixedMenuStillClosesItOnItsDigit() { "the terminal prompt becomes the 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 From cb6f8597543f5f938bcdd1987fa8ccf90153abf3 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 22:15:54 +0300 Subject: [PATCH 086/111] Retired a numbered menu at the panel's digit and ignored meta records as evidence A bare digit written from the panel answers an ordinary numbered menu on arrival, the same protocol writePtyAnswer() uses, but the recorder retired the menu only at the delayed Enter. A prompt typed during the pause was therefore classified as one more menu answer and swallowed. writeGuiPrompt() now retires the menu together with the text write; the following terminal Enter submits a fully recorded line, and a draft left for the delayed Enter is recorded by it. The /resume picker switch is noted at the write too, for a menu whose options are numbered. The exact prompt-evidence matcher accepted isMeta records, the system-injected user messages that can repeat the text an unrelated conversation holds. It now applies the same isHumanUserMessage() check the unknown-text matcher uses, so such a record cannot make that conversation look like this tab's destination. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 64 ++++++++++++++++--- .../process/ClaudeSessionStore.java | 4 ++ .../ClaudeSessionControllerTest.java | 54 ++++++++++++++++ .../process/ClaudeSessionStoreTest.java | 19 ++++++ 4 files changed, 132 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index c7f42670..11ac5e3d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -484,6 +484,17 @@ private static final class SessionClaims { */ 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 @@ -1981,6 +1992,7 @@ private void resetTerminalInputRecording(boolean keepCompletedMenu) { terminalGuiSubmitAt = 0L; guiSendAwaitingEnter = false; terminalMenuConsumedGuiSend = false; + terminalGuiTextAnsweredMenu = false; pendingGuiPromptText = null; pendingGuiPromptAt = 0L; pendingGuiPromptExact = false; @@ -2583,10 +2595,38 @@ private void writeGuiPrompt(String text, java.util.function.LongSupplier clock) throws IOException { synchronized (terminalInputLine) { reconcileGuiSend(text, clock.getAsLong()); + retireMenuAnsweredByGuiText(text); writePty(text); } } + /** + * 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)) return; + completeTerminalMenu(menu); + terminalGuiTextAnsweredMenu = true; + if (isConversationPicker(menu)) { + // Picking an entry changes the conversation, and the digit says + // nothing about which one was picked. + 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. @@ -2758,16 +2798,17 @@ void completeGuiSend(java.util.function.LongSupplier clock) { resetTerminalInputRecording(true); return; } - if (terminalMenuConsumedGuiSend || cancelledPicker) { + if (terminalMenuConsumedGuiSend || terminalGuiTextAnsweredMenu + || cancelledPicker) { // A terminal Enter already answered the choice menu this - // panel text was typed into, or a terminal Esc cancelled it, - // so 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. + // 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; - boolean draftAfterSelection = terminalDraftAfterEnter; try { writePty("\r"); } catch (IOException ex) { @@ -2776,7 +2817,10 @@ void completeGuiSend(java.util.function.LongSupplier clock) { return; } resetTerminalInputRecording(true); - if (draftAfterSelection && (!draftLine.isEmpty() || !draftExact)) { + 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; @@ -4681,13 +4725,15 @@ private String appendTerminalInput(byte[] bytes) { // 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) { + 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 diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index 4518a365..f8baaf2d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -494,6 +494,10 @@ private static boolean isUserMessageWith(String line, long sinceMillis, String p 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; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 04fccdb4..2a44345c 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -5263,6 +5263,60 @@ void aPickerCancelledDuringThePauseDoesNotEnableSwitchRecovery() throws Exceptio "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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 297f77c6..87ef7ba9 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -515,6 +515,25 @@ void containsUserPromptSince_noEvidence_returnsFalse() throws IOException { OLD_TS.toEpochMilli(), " ")); } + /** + * Claude writes system-injected messages with {@code isMeta}: they are + * {@code type: "user"} records carrying text this tab never typed, so one + * containing the same text must not be taken for this tab's prompt. + */ + @Test + void containsUserPromptSince_ignoresMetaRecordsWithTheSameText() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeJsonl(sessionDir.resolve("s1.jsonl"), + "{\"type\":\"user\",\"isMeta\":true,\"message\":{\"role\":\"user\"," + + "\"content\":\"this tabs prompt\"},\"timestamp\":\"" + + OLD_TS.plusSeconds(60) + "\"}"); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "this tabs prompt"), + "a system-injected record is not evidence of a submitted prompt"); + } + /** * Claude stores tool results as {@code type: "user"} records while it * works. A sibling tab finishing a tool must not look like a human prompt From 935efeeb6d52de420b70cf2e6eea1ad7d32aafaf Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 22:32:41 +0300 Subject: [PATCH 087/111] Kept the launch conversation when the PTY exited before the rename and matched whole messages only The exit callback released this tab's claims without keeping the conversations they held, so a PTY that quit on its own while the tab stayed open left Save & Switch without the pre-stop evidence: a launch conversation borrowed from a sibling tab looked like that tab's file, and an unrelated unheld conversation newer than the rename target took the title. The callback now snapshots the held conversations into preservedClaims, and stopAndRename() adds them to the stop-time snapshot. The exact prompt-evidence matcher accepted a single matching text block of a user message, so a longer message split into blocks ("continue" and "and run the deployment") looked exactly like the prompt "continue". Only the whole textual message is compared now. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 30 ++++++++--- .../process/ClaudeSessionStore.java | 5 +- .../ClaudeSessionControllerTest.java | 53 +++++++++++++++++++ .../process/ClaudeSessionStoreTest.java | 24 +++++++++ 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 11ac5e3d..3fe93065 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -259,16 +259,19 @@ private static final class SessionClaims { /** * Session IDs this tab established or guessed as its own before it stopped - * its writer, kept eligible for the final post-stop identity lookup of + * its writer — or before its process exited on its own while the tab stayed + * open — kept eligible for the final post-stop identity lookup of * {@link #stopAndRename(String)}. Stopping the PTY runs the exit callback, * which releases this tab's live claims; without the preserved evidence * this tab's own conversation would then look like another live tab's file * (or like a file established by another tab) and the lookup could pick an * unrelated conversation. * - *

    A conversation the tab only borrowed is not listed: it belongs to - * another live tab, and preserving it would make the borrowed file win over - * the file this tab actually created. + *

    A conversation the tab only borrowed is not listed by the stop-time + * snapshot: it belongs to another live tab, and preserving it would make the + * borrowed file win over the file this tab actually created. The exit-time + * snapshot does carry a borrowed launch conversation: the borrow ends with + * the process, and the tab still has to rename the file it was launched on. */ private volatile Set preservedClaims = Set.of(); @@ -1129,8 +1132,11 @@ public void stopAndRename(String sessionName) { // 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. - Set held = claimedSessionIds(dir, configDir); + // 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. + Set held = new HashSet<>(preservedClaims); + held.addAll(claimedSessionIds(dir, configDir)); stopWriter(); sessionId = resolveIdentityWithPreservedClaims(held); } @@ -2395,8 +2401,20 @@ private boolean isProcessLive() { private void onProcessExited(long generation) { synchronized (processOwnershipLock) { if (generation != processGeneration) return; + // The tab stays open after Claude quits on its own, and Save & + // Switch can still rename the conversation that was on screen. The + // claims are about to be released, so the conversations they held + // are carried into that later lookup; a borrowed conversation that + // is not the launch identity stays eligible to its owner. + java.io.File dir = workingDir != null ? workingDir : model.getWorkingDirectory(); + Set held = claimedSessionIds(dir, activeConfigDir); LIVE_SESSIONS.remove(this); releaseAllClaims(); + if (!held.isEmpty()) { + Set merged = new HashSet<>(preservedClaims); + merged.addAll(held); + preservedClaims = Set.copyOf(merged); + } } } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java index f8baaf2d..1e1679d1 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStore.java @@ -505,11 +505,14 @@ private static boolean isUserMessageWith(String line, long sinceMillis, String p 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 (isSamePrompt(text, prompt)) return true; if (joined.length() > 0) joined.append('\n'); joined.append(text); } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 2a44345c..e817ecb8 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -2958,6 +2958,59 @@ void stopWriter() { "the unrelated conversation must keep its name"); } + /** + * The PTY can exit on its own (Claude quit or crashed) while the tab stays + * open, and the user then closes it with a new name. The exit callback has + * already released this tab's claims, so a conversation it only borrowed is + * no longer evidence by the time Save & Switch resolves the target: it + * must be carried over from the exit, or an unrelated unheld file takes the + * rename just because it is newer. + */ + @Test + void aTabWhoseProcessExitedKeepsItsLaunchConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + // The first tab owns the saved conversation. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // The second tab resumed the same conversation and so only borrows it. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "noise")); + + // An unrelated conversation is written after the launch but stays + // older than the launch conversation. + appendUserPrompt(dir, "noise", "unrelated work", START_MS + 10_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 11_000L); + appendUserPrompt(dir, "a", "from the first tab", START_MS + 11_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_000L); + + // Claude quits on its own while the tab stays open. + tabB.simulateProcessExit(); + + tabB.stopAndRename("Shared Name"); + + assertEquals("Shared Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation is renamed, not a newer unrelated file"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "noise").customTitle(), + "the unrelated conversation must keep its name"); + } + /** * A NEW tab temporarily borrows a live sibling's conversation because both * submitted the same text before its own file was flushed. The borrowed diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java index 87ef7ba9..93b30c3e 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ClaudeSessionStoreTest.java @@ -534,6 +534,30 @@ void containsUserPromptSince_ignoresMetaRecordsWithTheSameText() throws IOExcept "a system-injected record is not evidence of a submitted prompt"); } + /** + * A user message can be split into several text blocks. Only the whole + * textual message is the prompt: accepting an individual block that + * matches would let a longer unrelated message look like this tab's + * prompt. + */ + @Test + void containsUserPromptSince_requiresTheWholeMessageAcrossBlocks() throws IOException { + Path workDir = tmpDir.resolve("myproject"); + Path sessionDir = createSessionDir(tmpDir, workDir); + writeJsonl(sessionDir.resolve("s1.jsonl"), + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"continue\"}," + + "{\"type\":\"text\",\"text\":\"and run the deployment\"}]}," + + "\"timestamp\":\"" + OLD_TS.plusSeconds(60) + "\"}"); + + assertFalse(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "continue"), + "a block of a longer message is not the whole prompt"); + assertTrue(ClaudeSessionStore.containsUserPromptSince(workDir, tmpDir, "s1", + OLD_TS.toEpochMilli(), "continue\nand run the deployment"), + "the whole textual message is the prompt"); + } + /** * Claude stores tool results as {@code type: "user"} records while it * works. A sibling tab finishing a tool must not look like a human prompt From 75f532eebb9a35c33e0b7a54380abd3bcbe37a73 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 23:09:34 +0300 Subject: [PATCH 088/111] Cancelled a paused panel send before the closing lookup and bound writes to their process A panel send paused between its text and its Enter completed after the writer was stopped and published its evidence before the final identity lookup found the launch conversation; an unrelated conversation that received the same text then took the rename. stopAndRename() now cancels the send queue and waits for a sender that is already completing before it takes the evidence snapshot, and runGuiSend() drops a task whose worker was cancelled while it waited for the send lock. The connector is now bound to the process generation a send was accepted for before the recorder lock is taken, for both the text write and the Enter. A restart that replaces the connector while a sender waits for that lock can no longer deliver the stale prompt or its Enter into the replacement TUI's input line; the captured connector is either the old one or null, so the write is dropped instead. Switch evidence from a panel command is kept pending until its Enter is written and is discarded when keys edited the line before the submitting Enter. A /resume followed by Ctrl+U and a new prompt no longer marks a conversation switch, so the resolver does not adopt an unrelated newer unheld conversation instead of the launch one. Tests cover the paused send released during stopWriter(), the stale text write and Enter against a connector installed after a restart, and the edited-away switch command; each failed before the fix. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 286 +++++++++++++++--- .../ClaudeSessionControllerTest.java | 223 ++++++++++++++ 2 files changed, 465 insertions(+), 44 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 3fe93065..38d7ac4a 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -103,6 +103,7 @@ public class ClaudeSessionController { private volatile ClaudeProcess claudeProcess; private PtyTtyConnector connector; private volatile java.util.function.Consumer ptyWriteHook; + private volatile java.util.function.Consumer ptyTargetHook; private java.io.File workingDir; private volatile JediTermWidget terminalWidget; @@ -470,6 +471,15 @@ private static final class SessionClaims { */ 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; + /** * 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 @@ -518,6 +528,17 @@ private static final class SessionClaims { /** 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; + /** Screen-poll ticks since the last background session-identity scan. */ private int identityPollTicks = 0; @@ -660,7 +681,16 @@ String simulateGuiSend(String text, long sentAt) { * {@link #completeGuiSend()} adds. */ void simulateGuiSendWithText(String text, long sentAt) throws IOException { - writeGuiPrompt(text, () -> sentAt); + writeGuiPrompt(text, () -> sentAt, currentProcessGeneration()); + } + + /** + * {@link #simulateGuiSendWithText(String, long)} for a send accepted for + * {@code generation}, so a test can deliver it after a restart replaced the + * connector. + */ + void simulateGuiSendWithText(String text, long sentAt, long generation) throws IOException { + writeGuiPrompt(text, () -> sentAt, generation); } /** @@ -676,12 +706,12 @@ void simulateSendPrompt(String text, java.util.function.LongSupplier writeClock, if (!isProcessGenerationCurrent(sendGeneration)) { return; } - writeGuiPrompt(text, writeClock); + writeGuiPrompt(text, writeClock, sendGeneration); pause.run(); if (!isProcessGenerationCurrent(sendGeneration)) { return; } - completeGuiSend(enterClock); + completeGuiSend(enterClock, sendGeneration); } } @@ -693,6 +723,17 @@ java.util.concurrent.Future simulateQueueGuiSend(String text) { return queueGuiSend(text, currentProcessGeneration()); } + /** + * Package-private for tests: submits a send through the ordered queue with + * controllable text-write and submission times, so a test can release it + * during a stop and check what its late Enter would have recorded. + */ + java.util.concurrent.Future simulateQueueGuiSend(String text, + java.util.function.LongSupplier writeClock, + java.util.function.LongSupplier enterClock) { + return queueGuiSend(text, currentProcessGeneration(), writeClock, enterClock); + } + /** Package-private for tests: the number of identity scans started. */ long simulateScanSequence() { synchronized (processOwnershipLock) { @@ -1124,21 +1165,30 @@ public void stopAndRename(String sessionName) { // attempted, so that work is skipped before the PTY is stopped. String sessionId = null; if (rename) { - 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. - Set held = new HashSet<>(preservedClaims); - held.addAll(claimedSessionIds(dir, configDir)); - stopWriter(); - sessionId = resolveIdentityWithPreservedClaims(held); + // 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. + shutdownGuiSendQueue(); + 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. + Set held = new HashSet<>(preservedClaims); + held.addAll(claimedSessionIds(dir, configDir)); + stopWriter(); + sessionId = resolveIdentityWithPreservedClaims(held); + } } stopProcess(); @@ -1996,6 +2046,7 @@ private void resetTerminalInputRecording(boolean keepCompletedMenu) { terminalEnterSinceGuiSend = false; terminalDraftAfterEnter = false; terminalGuiSubmitAt = 0L; + terminalGuiLineEdited = false; guiSendAwaitingEnter = false; terminalMenuConsumedGuiSend = false; terminalGuiTextAnsweredMenu = false; @@ -2003,6 +2054,7 @@ private void resetTerminalInputRecording(boolean keepCompletedMenu) { pendingGuiPromptAt = 0L; pendingGuiPromptExact = false; pendingGuiPromptSet = false; + pendingGuiPromptSwitch = false; if (!keepCompletedMenu) { terminalCompletedMenu = null; } @@ -2446,6 +2498,24 @@ private boolean isProcessGenerationCurrent(long generation) { } } + /** + * Returns the PTY connector of {@code generation}, or {@code null} when + * that run is gone or its PTY is not open anymore. + * + *

    Binding the connector to the generation the send was accepted for + * makes a write "atomic" with the process it targets: once a restart has + * replaced the connector, a send that was already past its generation + * check keeps writing to the process it was accepted for (whose connector + * then rejects or drops the bytes) and never to the replacement's TUI. + * + * @param generation process generation the writer was accepted for + */ + private PtyTtyConnector ptyConnectorFor(long generation) { + synchronized (processOwnershipLock) { + return generation == processGeneration ? connector : null; + } + } + /** Package-private for tests: replays the exit callback of an older run. */ void simulateProcessExitAt(long generation) { onProcessExited(generation); @@ -2496,6 +2566,17 @@ public void sendPrompt(String text) { * @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()) { @@ -2509,7 +2590,8 @@ private java.util.concurrent.Future queueGuiSend(String text, long sendGenera queue = guiSendQueue; } try { - return queue.submit(() -> runGuiSend(text, sendGeneration)); + 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. @@ -2539,9 +2621,24 @@ private void shutdownGuiSendQueue() { *

    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) { + 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; @@ -2552,7 +2649,7 @@ private void runGuiSend(String text, long sendGeneration) { // submit the terminal draft alone, while the evidence // recorded for the send would claim the composed line was // submitted. - writeGuiPrompt(text); + writeGuiPrompt(text, writeClock, sendGeneration); pauseGuiSend(); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); @@ -2578,7 +2675,7 @@ private void runGuiSend(String text, long sendGeneration) { LOG.fine("sendPrompt Enter dropped: the session was restarted"); return; } - completeGuiSend(); + completeGuiSend(enterClock, sendGeneration); } } @@ -2590,11 +2687,6 @@ void pauseGuiSend() throws InterruptedException { Thread.sleep(200); } - /** {@link #writeGuiPrompt(String, LongSupplier)} with the wall clock. */ - private void writeGuiPrompt(String text) throws IOException { - writeGuiPrompt(text, System::currentTimeMillis); - } - /** * 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 @@ -2608,13 +2700,24 @@ private void writeGuiPrompt(String text) throws IOException { *

    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) - throws IOException { + private void writeGuiPrompt(String text, java.util.function.LongSupplier clock, + long sendGeneration) throws IOException { + PtyTtyConnector target = ptyConnectorFor(sendGeneration); synchronized (terminalInputLine) { reconcileGuiSend(text, clock.getAsLong()); retireMenuAnsweredByGuiText(text); - writePty(text); + writePty(text, target); } } @@ -2702,16 +2805,16 @@ private String reconcileGuiSend(String text, long sentAt) { pendingGuiPromptAt = sentAt; pendingGuiPromptExact = isTextExact; pendingGuiPromptSet = true; - } - if (isConversationCommand(isTextExact ? submitted : text)) { // 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. - conversationSwitchAt = sentAt; + // 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); } return submitted; } @@ -2759,7 +2862,7 @@ private String reconcileGuiSend(String text, long sentAt) { * one has been flushed. */ void completeGuiSend() { - completeGuiSend(System::currentTimeMillis); + completeGuiSend(System::currentTimeMillis, currentProcessGeneration()); } /** @@ -2767,6 +2870,33 @@ void completeGuiSend() { * 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) { // Sampled before the Enter is forwarded: the child may append the @@ -2796,7 +2926,7 @@ void completeGuiSend(java.util.function.LongSupplier clock) { // not classify the input that follows as another menu // answer, or the next real prompt is swallowed. try { - writePty("\r"); + writePty("\r", target); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); resetTerminalInputRecording(true); @@ -2828,7 +2958,7 @@ void completeGuiSend(java.util.function.LongSupplier clock) { String draftLine = terminalInputLine.toString().trim(); boolean draftExact = !terminalHistoryRecall && !terminalCompletionEdited; try { - writePty("\r"); + writePty("\r", target); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); resetTerminalInputRecording(true); @@ -2844,7 +2974,7 @@ void completeGuiSend(java.util.function.LongSupplier clock) { return; } try { - writePty("\r"); + writePty("\r", target); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); resetTerminalInputRecording(true); @@ -2854,12 +2984,15 @@ void completeGuiSend(java.util.function.LongSupplier clock) { 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 switched = false; resetTerminalInputRecording(true); if (alreadySubmitted && draftAfterEnter && (!draftLine.isEmpty() || !draftExact)) { @@ -2878,9 +3011,12 @@ void completeGuiSend(java.util.function.LongSupplier clock) { // 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. + // 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 @@ -2911,6 +3047,9 @@ void completeGuiSend(java.util.function.LongSupplier clock) { 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 @@ -2927,10 +3066,18 @@ void completeGuiSend(java.util.function.LongSupplier clock) { // 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. + conversationSwitchAt = guiSubmitAt != 0L ? guiSubmitAt : submittedAt; + } } if (schedule) { scheduleIdentityRefresh(); @@ -4427,21 +4574,41 @@ private void sendCancelToPty(byte[] bytes) throws IOException { * captured by {@link #sendPrompt(String)}. */ private void writePty(byte[] bytes) throws IOException { + writePty(bytes, connector); + } + + /** + * Writes bytes to {@code target} — the PTY connector bound to the process + * a send was accepted for, or {@code null} — on behalf of the plugin. + * + *

    Writing through the bound connector instead of the current field + * keeps a delayed write out of a replacement process's input line: the old + * connector either accepts the bytes (its process is the one the send was + * meant for) or fails with an {@link IOException} once it is closed. + * + * @param bytes bytes to write + * @param target connector to write to, or {@code null} to drop the write + */ + private void writePty(byte[] bytes, PtyTtyConnector target) throws IOException { + java.util.function.Consumer targetHook = ptyTargetHook; + if (targetHook != null) { + targetHook.accept(target); + } java.util.function.Consumer hook = ptyWriteHook; if (hook != null) { hook.accept(bytes); return; } - if (connector == null) { - // No PTY yet (the launch is still being prepared): drop the write, - // as sendPrompt() does. + if (target == null) { + // No PTY for this send (it is still being prepared, or it was + // stopped or replaced): drop the write, as sendPrompt() does. LOG.fine("[PTY write] dropped, no connector"); return; } boolean previous = pluginPtyWrite.get(); pluginPtyWrite.set(true); try { - connector.write(bytes); + target.write(bytes); } finally { pluginPtyWrite.set(previous); } @@ -4456,11 +4623,39 @@ void simulatePtyWrite(java.util.function.Consumer hook) { ptyWriteHook = hook; } + /** + * Package-private for tests: receives the PTY connector each plugin write + * was bound to, or {@code null} when the write was dropped. Written to + * before the bytes, so a test can tell the accepted process from the + * connector installed by a restart. + */ + void simulatePtyWriteTargets(java.util.function.Consumer hook) { + ptyTargetHook = hook; + } + + /** + * Package-private for tests: attaches {@code replacement} as the current + * PTY connector, as a restart does once the new process is launched. The + * process generation is not changed: it is up to the test to simulate the + * restart itself. + */ + void simulateConnector(PtyTtyConnector replacement) { + connector = replacement; + } + /** Writes text to the PTY on behalf of the plugin. See {@link #writePty(byte[])}. */ private void writePty(String text) throws IOException { writePty(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); } + /** + * Writes text to the given connector. See + * {@link #writePty(byte[], PtyTtyConnector)}. + */ + private void writePty(String text, PtyTtyConnector target) throws IOException { + writePty(text.getBytes(java.nio.charset.StandardCharsets.UTF_8), target); + } + /** * Records input typed in the terminal (called from the PTY connector, on * the thread that wrote to the PTY). @@ -4514,8 +4709,11 @@ private String onTerminalInput(byte[] bytes, long now) { // 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. + // 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 (entered && !emptyEnter) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index e817ecb8..b1974740 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3011,6 +3011,87 @@ void aTabWhoseProcessExitedKeepsItsLaunchConversation() throws Exception { "the unrelated conversation must keep its name"); } + /** + * A panel send can still be paused between its text and its Enter when the + * user closes the tab. Its Enter never reaches the PTY, but if it completes + * after the writer was stopped, its evidence replaces the last submitted + * prompt before the final identity lookup runs: an unrelated conversation + * that received the same text after that bogus cutoff then takes the + * rename instead of the launch conversation. + */ + @Test + void aPausedSendDoesNotReplaceTheEvidenceOfTheFinalLookup() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + CountDownLatch paused = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference> send = new AtomicReference<>(); + ClaudeSessionController tab = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() throws InterruptedException { + paused.countDown(); + // A stop cancels the queue by interrupting this sender. + release.await(); + } + + @Override + void stopWriter() { + // Let the paused send finish right here: the identity + // snapshot is taken after this method returns. + release.countDown(); + java.util.concurrent.Future queued = send.get(); + if (queued != null) { + try { + queued.get(2, TimeUnit.SECONDS); + } catch (Exception ignored) { + // Cancelled by the stop: the expected outcome. + } + } + super.stopWriter(); + } + }; + tab.simulateProcessStart(START_MS, true); + tab.simulateLiveSession(config, "a", Set.of("a", "noise")); + // The launch conversation holds the last prompt this tab submitted. + tab.simulateGuiSend("hello", START_MS + 2_000L); + tab.completeGuiSend(() -> START_MS + 2_000L); + appendUserPrompt(dir, "a", "hello", START_MS + 2_100L); + setMtime(dir.resolve("a.jsonl"), START_MS + 2_200L); + + // A second panel send is written and pauses before its Enter. + send.set(tab.simulateQueueGuiSend("delayed words", + () -> START_MS + 3_000L, () -> START_MS + 20_000L)); + assertTrue(paused.await(5, TimeUnit.SECONDS), "the second send must pause"); + + // An unrelated conversation receives the same text after the send's + // would-be submission and is newer than the launch conversation. + appendUserPrompt(dir, "noise", "delayed words", START_MS + 20_100L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 20_200L); + + try { + tab.stopAndRename("Closed Name"); + } finally { + tab.stopProcess(); + } + + assertEquals("Closed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation is renamed, not the lookalike file"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "noise").customTitle(), + "the unrelated conversation must keep its name"); + } + /** * A NEW tab temporarily borrows a live sibling's conversation because both * submitted the same text before its own file was flushed. The borrowed @@ -3306,6 +3387,7 @@ void aSharedLaunchFileMatchYieldsToAGuiDrivenSwitch() throws Exception { // The user switches the conversation from the GUI: /resume from // the prompt panel, then the session picked from the menu. controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); controller.simulateGuiChoiceAnswer("ARROW:1"); // The new conversation is not followed by the identity poll yet @@ -3365,6 +3447,7 @@ void anUnknownPromptFollowsAGuiSwitchIntoAnUnclaimedConversation() throws Except // The user switches the conversation from the GUI: /resume from // the prompt panel, then the session picked from the menu. controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); controller.simulateGuiChoiceAnswer("ARROW:1"); // A recalled prompt: its text is unknown. @@ -3388,6 +3471,60 @@ void anUnknownPromptFollowsAGuiSwitchIntoAnUnclaimedConversation() throws Except } } + /** + * The panel writes /resume, then the user replaces the line during the + * pause: Ctrl+U clears it and hello is typed and submitted instead. The + * Enter does not switch the conversation, so the discarded command must + * not stay as switch evidence — otherwise the resolver's switch recovery + * adopts an unrelated newer unheld record instead of the launch + * conversation that received hello. + */ + @Test + void aSwitchCommandEditedAwayDoesNotSwitchTheConversation() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "noise", "noise"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("noise.jsonl"), START_MS - 5_000L); + + // This tab and a second one launched the same saved conversation, so + // only one of them owns it. + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "noise")); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a", "noise")); + + try { + controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.simulateTerminalInput(new byte[]{0x15}, START_MS + 10_050L); + controller.simulateTerminalInput( + "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_060L); + controller.completeGuiSend(() -> START_MS + 10_100L); + + // hello landed in the launch conversation. + appendUserPrompt(dir, "a", "hello", START_MS + 10_150L); + setMtime(dir.resolve("a.jsonl"), START_MS + 10_250L); + // An unrelated conversation received hello later and is newer. + appendUserPrompt(dir, "noise", "hello", START_MS + 10_160L); + setMtime(dir.resolve("noise.jsonl"), START_MS + 10_600L); + + assertEquals("a", controller.resolveActiveSessionId(), + "an edited-away switch must leave the launch conversation"); + } finally { + tabB.stopProcess(); + } + } + /** * The panel text reaches the conversation only when the GUI send's Enter is * written. Publishing it as own-session evidence sooner would let an @@ -3620,6 +3757,7 @@ void anUnheldOlderRecordDoesNotDisplaceTheSharedConversation() throws Exception try { // The user switches to the live conversation b from the GUI. controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); controller.simulateGuiChoiceAnswer("ARROW:1"); // A recalled prompt: its text is unknown. @@ -3679,6 +3817,7 @@ void aResolvedOwnConversationSurvivesALaterGuiSwitchSearch() throws Exception { try { // The user switches the conversation from the GUI. controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); controller.simulateGuiChoiceAnswer("ARROW:1"); // A literal prompt resolves the unclaimed destination b as this @@ -3942,6 +4081,7 @@ void aNewTabResumingALiveConversationDoesNotAdoptAnExitedTabsRecord() throws Exc try { // The user resumes the live conversation b from the GUI. controller.simulateGuiSend("/resume", START_MS + 10_000L); + controller.completeGuiSend(() -> START_MS + 10_050L); controller.simulateGuiChoiceAnswer("ARROW:1"); // A recalled prompt: its text is unknown. @@ -4530,6 +4670,87 @@ void pauseGuiSend() throws InterruptedException { "the sends must be delivered by one ordered worker, not competing threads"); } + /** Starts {@code /bin/cat} in a PTY for a test that needs a real connector. */ + private static com.pty4j.PtyProcess startEchoPty() throws Exception { + return new com.pty4j.PtyProcessBuilder(new String[]{"/bin/cat"}) + .setEnvironment(java.util.Map.of("TERM", "xterm")) + .setDirectory(System.getProperty("java.io.tmpdir")) + .setConsole(false) + .setRedirectErrorStream(true) + .start(); + } + + /** + * A sender can pass its generation check and then wait for the recorder's + * lock while a restart replaces the connector. The text must still be + * bound to the process the send was accepted for: writing it through the + * current connector would leave the stale prompt on the replacement TUI's + * input line for its next submission. + */ + @Test + void aSendAcceptedBeforeTheRestartDoesNotReachTheReplacementConnector() + throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + io.github.nbplugins.claudecodegui.process.PtyTtyConnector replacement = + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p); + long accepted = controller.simulateProcessGeneration(); + List targets = + new ArrayList<>(); + controller.simulatePtyWrite(bytes -> { }); + controller.simulatePtyWriteTargets(targets::add); + + // The tab is restarted: the accepted process is gone and a new PTY + // is already attached. + controller.stopProcess(); + controller.simulateConnector(replacement); + + controller.simulateGuiSendWithText("stale text", START_MS, accepted); + + assertEquals(1, targets.size(), "the text must be written once"); + assertNull(targets.get(0), + "the text must not be bound to the replacement connector"); + } finally { + p.destroyForcibly(); + } + } + + /** + * The Enter of a send whose process was replaced must not be written to + * the replacement TUI: it would submit whatever line that process has on + * its input line, and the evidence would describe a submission the + * accepted conversation never received. + */ + @Test + void aStaleEnterIsDroppedInsteadOfSubmittingTheReplacementProcess() + throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + io.github.nbplugins.claudecodegui.process.PtyTtyConnector replacement = + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p); + long accepted = controller.simulateProcessGeneration(); + List targets = + new ArrayList<>(); + controller.simulatePtyWrite(bytes -> { }); + controller.simulatePtyWriteTargets(targets::add); + + controller.simulateGuiSend("hello", START_MS); + controller.stopProcess(); + controller.simulateConnector(replacement); + + controller.completeGuiSend(() -> START_MS + 100L, accepted); + + assertTrue(targets.isEmpty(), + "no Enter may be written after the accepted process was replaced"); + } finally { + p.destroyForcibly(); + } + } + /** * Text typed during the pause and then submitted by the terminal Enter is * only a part of the line (the panel text is not in the recorder), so the @@ -5599,6 +5820,7 @@ void aComposedResumeCommandFollowsTheUnclaimedDestination() throws Exception { 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( @@ -5664,6 +5886,7 @@ void theSwitchRecoveryKeepsItsOwnFileForLaterUnknownPrompts() throws Exception { 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); From 4f57669f548e329788cdf4d0030e5900cee5308b Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 23:37:18 +0300 Subject: [PATCH 089/111] Kept a submitted panel prompt on cancellation and left the replacement recorder untouched When a terminal Enter submits the panel line during the pause and the stop then cancels the sender, the completion that publishes that evidence never runs. The recorder reset was discarding a real submission, so the identity lookup saw only unknown text and fell back to the launch conversation's newer record instead of following the conversation that received the prompt. The cancellation path now publishes the pending evidence with the submitting Enter's time (and the pending switch) before clearing the recorder, in the same critical section. Senders now re-check their process generation under the recorder lock and leave the recorder untouched when the run was replaced while they waited: reconciling rewrote the replacement's fresh draft and installed pending state for a line the replacement never received. The generation is read volatile there because the stop path resets the recorder while holding the ownership lock, so that lock cannot be nested inside the recorder lock. A digit typed while a menu's text-input option has the cursor is text for that editor, not a selection of the item it numbers: the answer protocol navigates such menus with arrows. The dismissal predicate now checks the focused option, so a mixed menu stays open and its answer is not recorded as a submitted prompt. Tests cover the cancelled send after a terminal submission, the stale sender against the replacement's draft, and the digit in the focused editor; each failed before the fix. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 103 +++++++++++- .../ClaudeSessionControllerTest.java | 153 +++++++++++++++++- 2 files changed, 247 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 38d7ac4a..be20c3d2 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -189,9 +189,14 @@ private static final class SessionClaims { * Guards {@link #claudeProcess} replacement against a late exit callback: * every started process gets a new {@link #processGeneration}, and the * callback only releases ownership when its generation is still current. + * + *

    {@link #processGeneration} is volatile for the senders that check it + * while holding {@link #terminalInputLine}: the stop path resets the + * recorder under the ownership lock, so that lock cannot be taken there + * (see {@link #recorderBelongsTo(long)}). */ private final Object processOwnershipLock = new Object(); - private long processGeneration; + private volatile long processGeneration; /** * Monotonic scan counter. Scans of the same process run can overlap (the @@ -2131,9 +2136,22 @@ private static boolean isCheckboxMenu(ChoiceMenuModel menu) { * 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. */ private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu, char digit) { if (menu.options().isEmpty()) return false; + int focused = menu.defaultOptionIndex(); + 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(); @@ -2516,6 +2534,28 @@ private PtyTtyConnector ptyConnectorFor(long generation) { } } + /** + * 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); @@ -2662,7 +2702,11 @@ private void runGuiSend(String text, long sendGeneration, // not restored: the worker is about to stop, and a stale flag // would make the next queued pause fail immediately. if (isProcessGenerationCurrent(sendGeneration)) { - resetTerminalInputRecording(true); + // 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. + discardCancelledGuiSend(); } return; } @@ -2715,6 +2759,14 @@ 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); @@ -2819,6 +2871,45 @@ private String reconcileGuiSend(String text, long sentAt) { 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. + * + *

    Publication and the reset share one critical section: input arriving + * in between would be cleared as if it belonged to the cancelled send. + */ + private void discardCancelledGuiSend() { + synchronized (terminalInputLine) { + long submittedAt = terminalGuiSubmitAt; + if (submittedAt != 0L && !terminalDraftAfterEnter + && !terminalInputSinceGuiSend && 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 (terminalInputSinceGuiSend) or a draft + // submitted after it already recorded the submission as unknown + // text, which is left in place. + rememberSentPrompt(pendingGuiPromptText, submittedAt, pendingGuiPromptExact); + if (pendingGuiPromptSwitch && !terminalGuiLineEdited) { + // The command's line was submitted untouched, so it switched. + 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)}. @@ -2899,6 +2990,14 @@ void completeGuiSend(java.util.function.LongSupplier clock, long generation) { } 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index b1974740..d6fd7566 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3092,6 +3092,86 @@ void stopWriter() { "the unrelated conversation must keep its name"); } + /** + * A terminal Enter during the pause submits the panel line for real. When a + * stop then cancels the sender, the completion that would normally publish + * that submission never runs: the lookup sees only unknown-text evidence + * and, after an in-process resume into a live conversation, falls back to + * the newer user record in the launch file instead of following the + * conversation that actually received the prompt. + */ + @Test + void aTerminalSubmissionDuringThePauseSurvivesTheCancelledSend() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + CountDownLatch paused = new CountDownLatch(1); + AtomicReference> send = new AtomicReference<>(); + long terminalEnterAt = START_MS + 11_100L; + ClaudeSessionController tab = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() throws InterruptedException { + // The user presses Enter in the terminal: the line the + // panel text was written to is submitted for real. + simulateTerminalInput(new byte[]{'\r'}, terminalEnterAt); + paused.countDown(); + // Only the stop's interruption releases this sender. + new CountDownLatch(1).await(); + } + }; + tab.simulateProcessStart(START_MS, true); + // This tab launched the saved conversation a; a sibling tab holds b. + tab.simulateLiveSession(config, "a", Set.of("a", "b")); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + // The user resumes b from the prompt panel: the conversation is on + // b, but this tab's identity has not been re-resolved yet. + tab.simulateGuiSend("/resume", START_MS + 10_000L); + tab.completeGuiSend(() -> START_MS + 10_050L); + tab.simulateGuiChoiceAnswer("ARROW:1"); + + // The next panel send is written and pauses before its Enter. + send.set(tab.simulateQueueGuiSend("delayed words", + () -> START_MS + 11_000L, () -> START_MS + 20_000L)); + assertTrue(paused.await(5, TimeUnit.SECONDS), "the second send must pause"); + + // The terminal Enter submitted the panel text into b. The launch + // file receives a later user record, so unknown-text time matching + // would pick it instead of b, which holds the submitted prompt. + appendUserPrompt(dir, "b", "delayed words", START_MS + 11_150L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_200L); + appendUserPrompt(dir, "a", "later work", START_MS + 11_300L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_400L); + + tab.stopAndRename("Closed Name"); + } finally { + tab.stopProcess(); + tabB.stopProcess(); + } + + assertEquals("Closed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the conversation the terminal Enter submitted into is renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation must keep its name"); + } + /** * A NEW tab temporarily borrows a live sibling's conversation because both * submitted the same text before its own file was flushed. The borrowed @@ -4682,10 +4762,10 @@ private static com.pty4j.PtyProcess startEchoPty() throws Exception { /** * A sender can pass its generation check and then wait for the recorder's - * lock while a restart replaces the connector. The text must still be - * bound to the process the send was accepted for: writing it through the - * current connector would leave the stale prompt on the replacement TUI's - * input line for its next submission. + * lock while a restart replaces the connector. The text must not be written + * at all: through the current connector it would leave the stale prompt on + * the replacement TUI's input line for its next submission, and the + * recorder it would reconcile belongs to the replacement too. */ @Test void aSendAcceptedBeforeTheRestartDoesNotReachTheReplacementConnector() @@ -4709,9 +4789,8 @@ void aSendAcceptedBeforeTheRestartDoesNotReachTheReplacementConnector() controller.simulateGuiSendWithText("stale text", START_MS, accepted); - assertEquals(1, targets.size(), "the text must be written once"); - assertNull(targets.get(0), - "the text must not be bound to the replacement connector"); + assertTrue(targets.isEmpty(), + "the text of a replaced run must not be written"); } finally { p.destroyForcibly(); } @@ -4751,6 +4830,43 @@ void aStaleEnterIsDroppedInsteadOfSubmittingTheReplacementProcess() } } + /** + * A sender that blocked on the recorder's lock while a restart replaced the + * process must not reconcile its recorder either: the pending state and + * the cleared line would belong to the old send, and the replacement's own + * draft would be lost — a later panel send would then compose only its own + * text and the identity lookup could follow an unrelated file. + */ + @Test + void aStaleSendDoesNotRewriteTheReplacementRecordersDraft() throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + io.github.nbplugins.claudecodegui.process.PtyTtyConnector replacement = + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p); + long accepted = controller.simulateProcessGeneration(); + controller.simulatePtyWrite(bytes -> { }); + controller.simulatePtyWriteTargets(t -> { }); + + // The tab is restarted and the replacement's user types a draft. + controller.stopProcess(); + controller.simulateConnector(replacement); + controller.simulateTerminalInput( + "fresh".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS); + + // The old sender only now runs its reconciliation. + controller.simulateGuiSendWithText("stale text", START_MS + 100L, accepted); + + assertEquals("fresh suffix", + controller.simulateGuiSend(" suffix", START_MS + 200L), + "the replacement's draft must survive the stale send"); + } finally { + p.destroyForcibly(); + } + } + /** * Text typed during the pause and then submitted by the terminal Enter is * only a part of the line (the panel text is not in the recorder), so the @@ -4965,6 +5081,29 @@ void anOrdinaryChoiceInAMixedMenuStillClosesItOnItsDigit() { "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 names no item selects nothing: the TUI keeps the menu open, * so the recorder must not retire it — otherwise the text typed next is From dc1d4de9c9e2161d256fb800c3d1092a4fbf55bb Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 10 Nov 2026 23:52:29 +0300 Subject: [PATCH 090/111] Tracked the text editor a digit activates and kept a submission through later typing The screen poll is what moves a choice menu's cursor onto its text editor, so after a digit activated the editor the model still reported the previous focus. Digits typed before the poll then looked like ordinary selections: a 3 (Type something.) followed by 1 feedback retired the menu at 1 and published "feedback" as an exact prompt. The activated menu is now remembered by identity until the poll replaces it or the menu completes, and digits typed meanwhile are treated as text for the editor. The cancellation path rejected a submitted panel line whenever the flags still showed keys typed after the submitting Enter, although such keys only start a new, unsubmitted line and leave the submitted text exact. Whether the line was edited is now read from terminalGuiLineEdited, captured at that Enter, so a draft typed afterwards no longer discards the real submission. Tests cover the digit sequence before the poll and a post-Enter draft followed by a cancelled send; both failed before the fix. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 77 +++++++++++++-- .../ClaudeSessionControllerTest.java | 97 +++++++++++++++++++ 2 files changed, 165 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index be20c3d2..01b1473d 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -485,6 +485,17 @@ private static final class SessionClaims { */ private boolean terminalGuiLineEdited; + /** + * The menu whose text-input option a typed or sent digit activated, while + * the screen poll has not yet reported the cursor on that editor. Until it + * does, {@link ChoiceMenuModel#defaultOptionIndex()} still names the + * previous option, and a digit naming an ordinary option would otherwise + * retire the menu although it is text for the editor. Compared by identity, + * so a menu instance published by a newer poll no longer matches; cleared + * when the menu is completed. Guarded by {@link #terminalInputLine}. + */ + private ChoiceMenuModel terminalMenuTextEditor; + /** * 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 @@ -2103,7 +2114,10 @@ private ChoiceMenuModel openChoiceMenu() { * while holding the {@link #terminalInputLine} lock. */ private void completeTerminalMenu(ChoiceMenuModel menu) { - if (menu != null) terminalCompletedMenu = menu; + if (menu != null) { + terminalCompletedMenu = menu; + if (terminalMenuTextEditor == menu) terminalMenuTextEditor = null; + } } /** @@ -2142,9 +2156,22 @@ private static boolean isCheckboxMenu(ChoiceMenuModel menu) { * 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 activation a digit performs is tracked separately + * ({@link #terminalMenuTextEditor}): the screen poll is what moves the + * cursor onto the editor, so the menu still reports the previous focus for + * every digit typed before it, and those digits are text for the editor all + * the same. + * + *

    Call while holding the {@link #terminalInputLine} lock. */ - private static boolean digitSelectionDismissesMenu(ChoiceMenuModel menu, char digit) { + private boolean digitSelectionDismissesMenu(ChoiceMenuModel menu, char digit) { if (menu.options().isEmpty()) return false; + if (menu == terminalMenuTextEditor) { + // A digit typed earlier activated this menu's editor and the poll + // has not confirmed the focus yet: printed digits are its text. + return false; + } int focused = menu.defaultOptionIndex(); if (focused >= 0 && focused < menu.options().size() && isTextInputOption(menu.options().get(focused))) { @@ -2170,6 +2197,23 @@ && isTextInputOption(menu.options().get(focused))) { return matched; } + /** + * Returns whether the digit {@code digit} activates the text editor of + * {@code menu} instead of selecting an item: that is what a digit naming a + * text-input option does. Call while holding the + * {@link #terminalInputLine} lock. + */ + private static boolean digitActivatesTextEditor(ChoiceMenuModel menu, char digit) { + for (ChoiceMenuModel.Option option : menu.options()) { + String response = option.response(); + if (response == null || response.length() != 1 || response.charAt(0) != digit) { + continue; + } + if (isTextInputOption(option)) return true; + } + return false; + } + /** Package-private for tests: the PTY is no longer running (Claude exited). */ void simulateProcessNotRunning() { claudeProcess = new ClaudeProcess(); @@ -2790,7 +2834,14 @@ private void retireMenuAnsweredByGuiText(String text) { char digit = text.charAt(0); if (digit < '0' || digit > '9') return; ChoiceMenuModel menu = openChoiceMenu(); - if (menu == null || !digitSelectionDismissesMenu(menu, digit)) return; + if (menu == null || !digitSelectionDismissesMenu(menu, digit)) { + if (menu != null && digitActivatesTextEditor(menu, digit)) { + // The sent digit activated the menu's text editor: the digits + // that follow are its text until the poll confirms the focus. + terminalMenuTextEditor = menu; + } + return; + } completeTerminalMenu(menu); terminalGuiTextAnsweredMenu = true; if (isConversationPicker(menu)) { @@ -2885,6 +2936,9 @@ private String reconcileGuiSend(String text, long sentAt) { *

    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. @@ -2892,16 +2946,15 @@ private String reconcileGuiSend(String text, long sentAt) { private void discardCancelledGuiSend() { synchronized (terminalInputLine) { long submittedAt = terminalGuiSubmitAt; - if (submittedAt != 0L && !terminalDraftAfterEnter - && !terminalInputSinceGuiSend && pendingGuiPromptSet + 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 (terminalInputSinceGuiSend) or a draft - // submitted after it already recorded the submission as unknown - // text, which is left in place. + // 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 && !terminalGuiLineEdited) { + if (pendingGuiPromptSwitch) { // The command's line was submitted untouched, so it switched. conversationSwitchAt = submittedAt; } @@ -5088,6 +5141,12 @@ private String appendTerminalInput(byte[] bytes) { // text accumulated while it was open must not prefix // that prompt's evidence (see the Right-submit path). discardPickerSearchText(); + } else if (digitActivatesTextEditor(openMenu, c)) { + // 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. + terminalMenuTextEditor = openMenu; } } else { insertTerminalChar(c); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index d6fd7566..d3de3064 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3172,6 +3172,79 @@ void pauseGuiSend() throws InterruptedException { "the launch conversation must keep its name"); } + /** + * A draft typed after the terminal Enter that submitted the panel line is + * a new, unsubmitted line: it must not hide the submission when a stop + * cancels the sender. Otherwise the exact panel text is lost as evidence + * and the lookup falls back to the launch conversation's newer record + * instead of following the resumed live conversation that received it. + */ + @Test + void aDraftAfterTheSubmittingEnterDoesNotHideTheSubmission() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + setMtime(dir.resolve("b.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + CountDownLatch paused = new CountDownLatch(1); + long terminalEnterAt = START_MS + 11_100L; + ClaudeSessionController tab = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() throws InterruptedException { + simulateTerminalInput(new byte[]{'\r'}, terminalEnterAt); + // The user starts another line without submitting it. + simulateTerminalInput( + "next".getBytes(java.nio.charset.StandardCharsets.UTF_8), + terminalEnterAt + 100L); + paused.countDown(); + // Only the stop's interruption releases this sender. + new CountDownLatch(1).await(); + } + }; + tab.simulateProcessStart(START_MS, true); + tab.simulateLiveSession(config, "a", Set.of("a", "b")); + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 1_000L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + try { + tab.simulateGuiSend("/resume", START_MS + 10_000L); + tab.completeGuiSend(() -> START_MS + 10_050L); + tab.simulateGuiChoiceAnswer("ARROW:1"); + + tab.simulateQueueGuiSend("delayed words", + () -> START_MS + 11_000L, () -> START_MS + 20_000L); + assertTrue(paused.await(5, TimeUnit.SECONDS), "the second send must pause"); + + appendUserPrompt(dir, "b", "delayed words", START_MS + 11_150L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_200L); + appendUserPrompt(dir, "a", "later work", START_MS + 11_300L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_400L); + + tab.stopAndRename("Closed Name"); + } finally { + tab.stopProcess(); + tabB.stopProcess(); + } + + assertEquals("Closed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the conversation that received the panel prompt is renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation must keep its name"); + } + /** * A NEW tab temporarily borrows a live sibling's conversation because both * submitted the same text before its own file was flushed. The borrowed @@ -5104,6 +5177,30 @@ void aDigitTypedIntoTheFocusedTextOptionDoesNotDismissTheMenu() { "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 From 7096a2a2c3b192dbf852fa9a76966ba3cf51f70c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 00:11:58 +0300 Subject: [PATCH 091/111] Ended the tracked text editor at the arrow and guarded the cancelled cleanup The digit-activation marker tracked only printed digits, but Up/Down move the menu cursor too, and the poll reports neither. After 3 activated the text editor and Up returned to the ordinary items, the stale marker kept treating the next digit as editor text: the menu stayed open and "hello" was consumed as a menu answer instead of being recorded as the prompt. An arrow seen while a menu is open now ends the activation. The cleanup of a cancelled or failed send reset the recorder whenever it ran, but a sender interrupted by a blank-name restart can reach it after the replacement process has already recorded input, so it erased that draft and a later panel prompt was published without the text the PTY submits. The cleanup now takes the accepted generation and rechecks it under the recorder lock before publishing evidence or resetting state. Tests cover the arrows after an activated editor and the late cleanup over a replacement draft; both failed before the fix. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 49 +++++++++++++----- .../ClaudeSessionControllerTest.java | 50 +++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 01b1473d..cf4c9746 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -2542,6 +2542,14 @@ long simulateProcessGeneration() { return currentProcessGeneration(); } + /** + * Package-private for tests: the cleanup a sender of {@code generation} + * runs when its send is cancelled or fails, without its thread timing. + */ + void simulateDiscardGuiSend(long generation) { + discardGuiSend(generation); + } + /** The ownership generation of the run that is live now. */ private long currentProcessGeneration() { synchronized (processOwnershipLock) { @@ -2737,21 +2745,19 @@ private void runGuiSend(String text, long sendGeneration, pauseGuiSend(); } catch (IOException ex) { LOG.warning("sendPrompt write failed: " + ex.getMessage()); - if (isProcessGenerationCurrent(sendGeneration)) { - resetTerminalInputRecording(true); - } + // 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. - if (isProcessGenerationCurrent(sendGeneration)) { - // 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. - discardCancelledGuiSend(); - } + // 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)) { @@ -2941,10 +2947,20 @@ private String reconcileGuiSend(String text, long sentAt) { * ({@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. + * 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 discardCancelledGuiSend() { + private void discardGuiSend(long generation) { synchronized (terminalInputLine) { + if (!recorderBelongsTo(generation)) { + LOG.fine("sendPrompt cleanup dropped: the recorder was replaced"); + return; + } long submittedAt = terminalGuiSubmitAt; if (submittedAt != 0L && !terminalGuiLineEdited && pendingGuiPromptSet && promptSubmittedAt <= submittedAt) { @@ -5220,7 +5236,14 @@ private void applyCursorKey(char key) { // 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. - if (terminalInputLine.isEmpty() && openChoiceMenu() == null) { + ChoiceMenuModel menu = openChoiceMenu(); + if (menu != null) { + // The cursor may have left the text editor a digit had + // activated. The model's index stays stale until the poll, + // so the tracked activation no longer proves the editor is + // active: digits are ordinary selections again. + terminalMenuTextEditor = null; + } else if (terminalInputLine.isEmpty()) { terminalHistoryRecall = true; } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index d3de3064..5f29dd6e 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3172,6 +3172,32 @@ void pauseGuiSend() throws InterruptedException { "the launch conversation must keep its name"); } + /** + * An Up/Down arrow moves the half-second-stale menu cursor off the text + * editor a digit had activated, and the poll does not report it yet: the + * tracked activation must end with the arrow, so the digit naming the + * item the user moved to selects it instead of being typed into an editor + * that no longer has the focus. + */ + @Test + void anArrowLeavingTheActivatedEditorRestoresDigitSelections() { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + // The digit activates the editor, then Up moves the cursor back to + // the ordinary items before the next poll. + controller.simulateTerminalInput(new byte[]{'3'}, START_MS); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 10L); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 20L); + + assertEquals("hello", controller.simulateTerminalInput( + "1hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the digit selects the item the cursor moved to and hello is the next prompt"); + } + /** * A draft typed after the terminal Enter that submitted the panel line is * a new, unsubmitted line: it must not hide the submission when a stop @@ -3245,6 +3271,30 @@ void pauseGuiSend() throws InterruptedException { "the launch conversation must keep its name"); } + /** + * A sender interrupted by a restart may reach its cleanup only after the + * replacement process recorded input. Its generation is stale by then, and + * the recorder holds the replacement's state: clearing it would erase the + * replacement's draft, so a following panel prompt would be published + * without the text the PTY actually submits. + */ + @Test + void aCancelledSendDoesNotClearTheReplacementRecordersDraft() { + long accepted = controller.simulateProcessGeneration(); + controller.simulateGuiSend("hello", START_MS); + + controller.stopProcess(); + controller.simulateTerminalInput( + "fresh".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L); + + // The interrupted sender's cleanup runs only now. + controller.simulateDiscardGuiSend(accepted); + + assertEquals("fresh suffix", controller.simulateGuiSend(" suffix", START_MS + 200L), + "the replacement's draft must survive the cancelled send's cleanup"); + } + /** * A NEW tab temporarily borrows a live sibling's conversation because both * submitted the same text before its own file was flushed. The borrowed From 6f8334be071073d687d20636f961ba4e5459663b Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 00:29:56 +0300 Subject: [PATCH 092/111] Tracked where arrows leave the menu cursor and killed the PTY before waiting Clearing the tracked text editor on an arrow only covered leaving it. An arrow can also enter a text-input option: Down twice from the first item landed on "Type something." while the model still reported the first one, so a following digit retired the menu as an ordinary selection and the rest of the answer became prompt evidence. The recorder now tracks the menu and the option the terminal input moved the cursor to, for both digit activation and arrows, and classifies digits against that position until the poll replaces the menu. stopAndRename waits for the panel sender while holding the send lock, but the sender can be blocked in a native write to a hung or non-reading process and the queue interrupt does not release it: the wait ran on the EDT and froze the IDE. The PTY child is now killed before the wait, which closes its end of the pipe and lets the write fail out. Tests cover the arrows entering the editor and a stop with a write blocked forever; both failed before the fix. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 160 +++++++++++++----- .../ClaudeSessionControllerTest.java | 81 +++++++++ 2 files changed, 196 insertions(+), 45 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index cf4c9746..d52bb180 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -103,6 +103,9 @@ public class ClaudeSessionController { private volatile ClaudeProcess claudeProcess; private PtyTtyConnector connector; private volatile java.util.function.Consumer ptyWriteHook; + + /** Package-private for tests: runs whenever the PTY child is killed. */ + private volatile Runnable ptyKillHook; private volatile java.util.function.Consumer ptyTargetHook; private java.io.File workingDir; private volatile JediTermWidget terminalWidget; @@ -486,15 +489,23 @@ private static final class SessionClaims { private boolean terminalGuiLineEdited; /** - * The menu whose text-input option a typed or sent digit activated, while - * the screen poll has not yet reported the cursor on that editor. Until it - * does, {@link ChoiceMenuModel#defaultOptionIndex()} still names the - * previous option, and a digit naming an ordinary option would otherwise - * retire the menu although it is text for the editor. Compared by identity, - * so a menu instance published by a newer poll no longer matches; cleared - * when the menu is completed. Guarded by {@link #terminalInputLine}. + * 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 terminalMenuTextEditor; + 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: @@ -1093,6 +1104,23 @@ public String getOpenAIProxyUuid() { */ 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 @@ -1107,10 +1135,7 @@ void stopWriter() { if (connector != null) { connector.setLineListener(null); } - ClaudeProcess processToStop = claudeProcess; - if (processToStop != null) { - processToStop.killOnly(); - } + killPtyProcess(); connector = null; } @@ -1187,6 +1212,12 @@ public void stopAndRename(String sessionName) { // 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(); synchronized (guiSendLock) { sessionId = resolveActiveSessionId(); @@ -2116,10 +2147,38 @@ private ChoiceMenuModel openChoiceMenu() { private void completeTerminalMenu(ChoiceMenuModel menu) { if (menu != null) { terminalCompletedMenu = menu; - if (terminalMenuTextEditor == menu) terminalMenuTextEditor = null; + 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; + } + /** * Returns whether {@code menu} is a checkbox menu, which submits on Right * (the answer protocol written by the menu panel) instead of a numbered @@ -2157,22 +2216,16 @@ private static boolean isCheckboxMenu(ChoiceMenuModel menu) { * see {@code writePtyAnswer}). Retiring the menu then would record the rest * of the answer as a submitted prompt. * - *

    The activation a digit performs is tracked separately - * ({@link #terminalMenuTextEditor}): the screen poll is what moves the - * cursor onto the editor, so the menu still reports the previous focus for - * every digit typed before it, and those digits are text for the editor all - * the same. + *

    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; - if (menu == terminalMenuTextEditor) { - // A digit typed earlier activated this menu's editor and the poll - // has not confirmed the focus yet: printed digits are its text. - return false; - } - int focused = menu.defaultOptionIndex(); + 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 @@ -2198,20 +2251,20 @@ && isTextInputOption(menu.options().get(focused))) { } /** - * Returns whether the digit {@code digit} activates the text editor of - * {@code menu} instead of selecting an item: that is what a digit naming a - * text-input option does. Call while holding the - * {@link #terminalInputLine} lock. + * 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 boolean digitActivatesTextEditor(ChoiceMenuModel menu, char digit) { - for (ChoiceMenuModel.Option option : menu.options()) { + 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 true; + if (isTextInputOption(option)) return i; } - return false; + return -1; } /** Package-private for tests: the PTY is no longer running (Claude exited). */ @@ -2841,10 +2894,11 @@ private void retireMenuAnsweredByGuiText(String text) { if (digit < '0' || digit > '9') return; ChoiceMenuModel menu = openChoiceMenu(); if (menu == null || !digitSelectionDismissesMenu(menu, digit)) { - if (menu != null && digitActivatesTextEditor(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. - terminalMenuTextEditor = menu; + trackMenuFocus(menu, activated); } return; } @@ -4791,6 +4845,14 @@ void simulatePtyWrite(java.util.function.Consumer hook) { ptyWriteHook = hook; } + /** + * Package-private for tests: runs when the PTY child is killed, so a test + * can release a write it made block as a hung process would. + */ + void simulatePtyKill(Runnable hook) { + ptyKillHook = hook; + } + /** * Package-private for tests: receives the PTY connector each plugin write * was bound to, or {@code null} when the write was dropped. Written to @@ -5157,12 +5219,15 @@ private String appendTerminalInput(byte[] bytes) { // text accumulated while it was open must not prefix // that prompt's evidence (see the Right-submit path). discardPickerSearchText(); - } else if (digitActivatesTextEditor(openMenu, c)) { - // 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. - terminalMenuTextEditor = openMenu; + } 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); @@ -5238,11 +5303,16 @@ private void applyCursorKey(char key) { // recalled prompt, so it must not be recorded as a submission. ChoiceMenuModel menu = openChoiceMenu(); if (menu != null) { - // The cursor may have left the text editor a digit had - // activated. The model's index stays stale until the poll, - // so the tracked activation no longer proves the editor is - // active: digits are ordinary selections again. - terminalMenuTextEditor = 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; } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 5f29dd6e..7de6d605 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3295,6 +3295,87 @@ void aCancelledSendDoesNotClearTheReplacementRecordersDraft() { "the replacement's draft must survive the cancelled send's cleanup"); } + /** + * Arrows can also enter a text editor: two Downs from the first item land + * on `Type something.`, and the poll does not report it yet. The digits + * typed in that window belong to the editor, so they must not retire the + * menu and turn the rest of the answer into prompt evidence. + */ + @Test + void arrowsEnteringTheEditorMakeTheFollowingDigitsItsText() { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'B'}, START_MS + 10L); + controller.simulateTerminalInput(new byte[]{0x1b, '[', 'B'}, START_MS + 20L); + + assertNull(controller.simulateTerminalInput( + "1feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the cursor is in the editor the arrows reached; the answer is not a prompt"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + } + + /** + * A sender blocked in a native write to a hung process cannot be released + * by interrupting its thread, and Save & Switch waits for it on the EDT. + * The PTY child is killed before the wait, so the write returns and the + * stop completes instead of freezing the IDE. + */ + @Test + void stopAndRenameUnblocksAWriteStuckOnAHungProcess() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateActiveSession(config, "a", Set.of("a")); + + CountDownLatch writing = new CountDownLatch(1); + CountDownLatch released = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + writing.countDown(); + // A hung process never drains the pipe: the interrupt that + // cancels the queue does not end this write. + while (true) { + try { + released.await(); + return; + } catch (InterruptedException ignored) { + // the native write ignores it too + } + } + }); + // Only killing the child releases the blocked write. + controller.simulatePtyKill(released::countDown); + controller.simulateQueueGuiSend("delayed words", + () -> START_MS + 10L, () -> START_MS + 20L); + assertTrue(writing.await(5, TimeUnit.SECONDS), "the send must reach its write"); + + java.util.concurrent.ExecutorService stopper = + java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + java.util.concurrent.Future stopped = stopper.submit( + () -> controller.stopAndRename("Closed Name")); + stopped.get(10, TimeUnit.SECONDS); + } finally { + released.countDown(); + stopper.shutdownNow(); + controller.stopProcess(); + } + + assertEquals("Closed Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the stop completes and still names the launched session"); + } + /** * A NEW tab temporarily borrows a live sibling's conversation because both * submitted the same text before its own file was flushed. The borrowed From 7f7fa452d51fd91338ea7b0b53d4ee94c54b7a35 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 00:48:51 +0300 Subject: [PATCH 093/111] Refused to rename an identity two tabs cannot tell apart and scoped the exclusion Two NEW tabs that submitted identical text both see the same prompt in the sibling's file first, and the file one of them writes later does not displace that provisional claim: the evidence is deliberately indistinguishable to keep the two tabs from swapping conversations. The stop path then renamed the tracked file, writing this tab's name into the sibling's conversation. Before the name is written, the identity is now checked: if another live tab holds it, this tab did not establish it at launch, and an unclaimed file with the same prompt exists, the rename is skipped and logged instead of guessed. A file a sibling merely follows has no such second candidate, so its creator is still renamed. The switch dialog excluded the running session by ID alone, but the store it lists can differ from the one the running process writes to (a profile's storage directory changed in Options). A copy of the conversation under the same ID then disappeared from Resume specific. The ID is now excluded only when both stores are the same path. Tests cover the ambiguous stop (no file renamed), the followed creator (still renamed), and the copied conversation (still listed); all failed before the fix. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 45 ++++++++ .../claudecodegui/ui/ClaudeSessionTab.java | 30 +++++- .../ClaudeSessionControllerTest.java | 100 ++++++++++++++++++ .../ui/ClaudeSessionTabPrefillNameTest.java | 21 ++++ 4 files changed, 195 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index d52bb180..d8fbf1ad 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1237,9 +1237,21 @@ public void stopAndRename(String sessionName) { sessionId = resolveIdentityWithPreservedClaims(held); } } + // Two NEW tabs that submitted identical text leave the identity + // unresolved: each tracks the file whose prompt matched first, and the + // other tab writes a file holding the same text. The name entered in the + // dialog must not be written into the sibling's conversation. + boolean ambiguous = rename + && identityRemainsAmbiguous(dir, configDir, sessionId); stopProcess(); if (!rename) return; + if (ambiguous) { + LOG.warning("Skipped renaming session " + sessionId + " in " + configDir + + ": another live tab writes the same conversation and another file" + + " holds this prompt; the identity cannot be told apart"); + return; + } if (dir == null || configDir == null || sessionId == null) { LOG.warning("Could not rename session: no active session file found in " + ClaudeSessionStore.sessionDirForLog( @@ -1261,6 +1273,39 @@ public void stopAndRename(String sessionName) { } } + /** + * Returns whether {@code sessionId} is a conversation this tab cannot prove + * it is the writer of: another live tab holds it as well, this tab has not + * established it at launch, and the store also holds a file with this run's + * prompt that no other live tab claims. + * + *

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

    Called before {@link #stopProcess()}, while this run's prompt evidence + * and the claim registry still describe the running session. + */ + private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path configDir, + String sessionId) { + if (sessionId == null || isLaunchIdentity(sessionId)) return false; + SessionKey key = keyFor(dir, configDir, sessionId); + SessionClaims claims = key != null ? SESSION_CLAIMS.get(key) : null; + if (claims == null || !heldByAnotherLiveTab(claims)) return false; + if (claims.owners.contains(this)) return false; + String promptText = lastPromptText; + long promptAt = promptSubmittedAt; + if (promptText == null || promptText.isBlank() || promptAt <= 0) return false; + // A file holding the same prompt that no other live tab claims could + // equally be the one this run wrote: the evidence cannot tell them apart. + Set shared = claimedByOthers(dir, configDir); + shared.add(sessionId); + return findPromptBearingSession(dir, configDir, 0L, shared, null, + promptAt, promptText) != null; + } + /** * Returns the ID of the session currently being displayed, resolving the * file created by this run for {@link SessionMode#NEW} sessions. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 2dbbc4c6..b3f02a61 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -1300,7 +1300,12 @@ public void openSwitchDialog(SessionMode initialMode) { 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, @@ -1311,6 +1316,29 @@ 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 7de6d605..19fac6b7 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -6809,6 +6809,106 @@ void twoUnresolvedNewTabsDoNotSwapTheConversationTheyShare() throws Exception { } } + /** + * 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"); + } + + /** + * 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java index fdde6628..bb436bb6 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabPrefillNameTest.java @@ -52,4 +52,25 @@ void titleEqualToSessionIdIgnored() { void nullSessionGivesEmptyString() { assertEquals("", ClaudeSessionTab.prefillSessionName(null)); } + + @Test + void runningSessionExcludedOnlyFromItsOwnStore() { + java.nio.file.Path running = java.nio.file.Path.of("/home/u/.claude"); + java.nio.file.Path profiles = java.nio.file.Path.of("/home/u/.claude-profiles/Work"); + assertEquals("id-1", + ClaudeSessionTab.excludedSessionId("id-1", running, running), + "the listed store is the running one: hide the running conversation"); + org.junit.jupiter.api.Assertions.assertNull( + ClaudeSessionTab.excludedSessionId("id-1", profiles, running), + "another store may hold a copy under the same ID, which must stay listed"); + org.junit.jupiter.api.Assertions.assertNull( + ClaudeSessionTab.excludedSessionId(null, running, running)); + } + + @Test + void storeComparisonNormalizesThePath() { + assertEquals("id-1", ClaudeSessionTab.excludedSessionId("id-1", + java.nio.file.Path.of("/home/u/./.claude"), + java.nio.file.Path.of("/home/u/.claude"))); + } } From 19349981ff8119993870878bef62e1abfd387791 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 11:48:05 +0300 Subject: [PATCH 094/111] Recorded the switch at the submitting Enter and kept typed answers in their menu A switch command written by the panel and submitted by a terminal Enter during the pause was only treated as a switch when the panel's completion Enter ran, and only while no later terminal submission had replaced the text evidence. A recalled prompt submitted before that completion therefore dropped the switch altogether, and the stop lookup followed the launch conversation instead of the one the command cleared into. The switch is now recorded by the Enter that actually submitted the untouched panel line. The GUI answer typed into a menu's editor retired the menu on dispatch, although the TUI keeps it open until the worker writes its Enter 400 ms later. A typed answer submitted with Enter in that window then looked like a prompt and was published as exact text evidence. The TYPE and MULTI_TYPE workers now retire the menu and the recorder's menu context after forwarding their submitting key. Tests cover the recalled submission after a submitted switch and the typed answer during the type-answer window; both failed before the fix. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 39 +++++++- .../ClaudeSessionControllerTest.java | 90 +++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index d8fbf1ad..7b6830e3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -3489,8 +3489,15 @@ public void writePtyAnswer(String answer) { // 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. - synchronized (terminalInputLine) { - completeTerminalMenu(openChoiceMenu()); + // An answer typed into the menu's editor is the exception: the TUI keeps + // it open until the worker's Enter, and terminal input arriving in + // between answers that editor, so the worker retires it. + boolean typedAnswer = answer != null + && (answer.startsWith("TYPE:") || answer.startsWith("MULTI_TYPE:")); + if (!typedAnswer) { + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } } resetTerminalInputRecording(true); try { @@ -3581,11 +3588,18 @@ public void writePtyAnswer(String answer) { 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); @@ -3625,11 +3639,20 @@ public void writePtyAnswer(String answer) { 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(); @@ -4989,6 +5012,14 @@ private String onTerminalInput(byte[] bytes, long now) { // 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. + conversationSwitchAt = now; + } } } if (entered && !emptyEnter) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 19fac6b7..69db50d1 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -3376,6 +3376,96 @@ void stopAndRenameUnblocksAWriteStuckOnAHungProcess() throws Exception { "the stop completes and still names the launched session"); } + /** + * A switch command submitted by a terminal Enter during the pause — the + * panel's untouched line — has switched the conversation. A later terminal + * submission of a recalled prompt replaces the text evidence, but the + * switch itself happened, and the stop-time lookup has to follow it to the + * conversation the command cleared into rather than the launch one. + */ + @Test + void aSubmittedSwitchSurvivesALaterRecalledSubmission() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + CountDownLatch paused = new CountDownLatch(1); + ClaudeSessionController tab = + new ClaudeSessionController(model, Collections::emptyList) { + @Override + void pauseGuiSend() { + // The terminal submits the untouched /clear, then the + // user recalls an earlier prompt and submits it too. + simulateTerminalInput(new byte[]{'\r'}, START_MS + 11_000L); + simulateTerminalInput(new byte[]{0x1b, '[', 'A'}, START_MS + 11_050L); + simulateTerminalInput(new byte[]{'\r'}, START_MS + 11_100L); + paused.countDown(); + } + }; + + try { + tab.simulateProcessStart(START_MS, true); + tab.simulateLiveSession(config, "a", Set.of("a")); + tab.simulateQueueGuiSend("/clear", + () -> START_MS + 10_000L, () -> START_MS + 11_200L); + assertTrue(paused.await(5, TimeUnit.SECONDS), "the send must pause"); + + // The launch conversation keeps receiving records, but the + // conversation /clear opened is strictly newer. + appendUserPrompt(dir, "a", "later work", START_MS + 11_250L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_300L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "recalled words", START_MS + 11_350L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_400L); + + tab.stopAndRename("Cleared Title"); + } finally { + tab.stopProcess(); + } + + assertEquals("Cleared Title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the conversation the submitted switch opened is renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the launch conversation keeps its name"); + } + + /** + * A GUI answer typed into a menu's text editor is forwarded with a delay: + * the TUI keeps the menu open until its Enter. A typed answer submitted + * with Enter in that window answers the same editor, so it is not a prompt + * and must not become prompt evidence. + */ + @Test + void aTypedAnswerDuringTheTypeAnswerIsNotAPrompt() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + controller.writePtyAnswer("TYPE:3:auto answer"); + + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the TUI is still on its editor: the typed answer is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the menu is retired once the answer has been forwarded"); + } + /** * A NEW tab temporarily borrows a live sibling's conversation because both * submitted the same text before its own file was flushed. The borrowed From ab835442a10c3ea4b9bdfc74fb5b237013dc0aa3 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 12:17:00 +0300 Subject: [PATCH 095/111] Kept a menu answer out of the prompt evidence and the shared guess ambiguous An ordinary permission-menu answer was advancing the external-input cutoff, which hid the earlier prompt submission from the identity scan: a tab that resumed another live tab's conversation then fell back to its launch conversation and Save & Switch renamed that file instead of the resumed one. Only a conversation-picker selection changes what the process writes, so only it (and plain terminal input) moves the cutoff now. The rename ambiguity guard also depended on the other writer still being alive. When the sibling NEW tab stopped first, its claim release made the guard return false and the name was written into the sibling's file. The ambiguity is now judged from the store, not the live claim registry: a file bearing this run's prompt and touched after the submission (and after any later terminal input) still competes, so neither file is renamed. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 40 ++++-- .../ClaudeSessionControllerTest.java | 118 ++++++++++++++++++ 2 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 7b6830e3..a8d74974 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1248,8 +1248,8 @@ public void stopAndRename(String sessionName) { if (!rename) return; if (ambiguous) { LOG.warning("Skipped renaming session " + sessionId + " in " + configDir - + ": another live tab writes the same conversation and another file" - + " holds this prompt; the identity cannot be told apart"); + + ": another file holds this prompt and the identity cannot be" + + " told apart"); return; } if (dir == null || configDir == null || sessionId == null) { @@ -1275,9 +1275,9 @@ public void stopAndRename(String sessionName) { /** * Returns whether {@code sessionId} is a conversation this tab cannot prove - * it is the writer of: another live tab holds it as well, this tab has not - * established it at launch, and the store also holds a file with this run's - * prompt that no other live tab claims. + * it is the writer of: this tab has not established it at launch, and the + * store also holds a file with this run's prompt that no other live tab + * claims. * *

    Two NEW tabs that submitted identical text leave exactly this state: * the prompt matched in the sibling's file first, so this tab tracks it @@ -1285,24 +1285,34 @@ public void stopAndRename(String sessionName) { * 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 - * and the claim registry still describe the running session. + * still describes the running session. */ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path configDir, String sessionId) { if (sessionId == null || isLaunchIdentity(sessionId)) return false; SessionKey key = keyFor(dir, configDir, sessionId); SessionClaims claims = key != null ? SESSION_CLAIMS.get(key) : null; - if (claims == null || !heldByAnotherLiveTab(claims)) return false; - if (claims.owners.contains(this)) return false; + if (claims != null && claims.owners.contains(this)) return false; String promptText = lastPromptText; long promptAt = promptSubmittedAt; if (promptText == null || promptText.isBlank() || promptAt <= 0) return false; // A file holding the same prompt that no other live tab claims could // equally be the one this run wrote: the evidence cannot tell them apart. + // 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; Set shared = claimedByOthers(dir, configDir); shared.add(sessionId); - return findPromptBearingSession(dir, configDir, 0L, shared, null, + return findPromptBearingSession(dir, configDir, since, shared, null, promptAt, promptText) != null; } @@ -5022,10 +5032,18 @@ private String onTerminalInput(byte[] bytes, long now) { } } } - if (entered && !emptyEnter) { + 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 (menuSelection && isConversationPicker(selectedMenu)) { + 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 69db50d1..94dcce0b 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -5812,6 +5812,65 @@ void anOrdinaryMenuAnsweredInThePanelDoesNotEnableSwitchRecovery() throws Except "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 @@ -6952,6 +7011,65 @@ void aStopDoesNotRenameEitherOfTwoSharedUnresolvedFiles() throws Exception { "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"); + } + /** * A NEW tab that created its conversation and is merely followed by a * sibling has nothing ambiguous about its file: the rename must still From 229080e4e518ef2ddc90d94de31a03d0aadcc85b Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 12:41:49 +0300 Subject: [PATCH 096/111] Snapshotted the live and preserved claims under one lock before a stop The stop path read preservedClaims and the live claim registry separately. A process that quit on its own moves its claims from one to the other under processOwnershipLock, so the two reads could straddle that transition and observe the old preserved set together with the already cleared live set. The final identity lookup then saw an empty snapshot, treated the shared launch conversation as another tab's file, and could rename an unrelated eligible conversation even though the shared one was the newest. Both sets are now captured inside the ownership lock. A test hook runs just before the snapshot so a process exit can be interleaved deterministically. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 28 ++++++++++- .../ClaudeSessionControllerTest.java | 49 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index a8d74974..40067b11 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -106,6 +106,12 @@ public class ClaudeSessionController { /** Package-private for tests: runs whenever the PTY child is killed. */ private volatile Runnable ptyKillHook; + + /** + * Package-private for tests: runs just before the stop path snapshots the + * session claims, so a concurrent process exit can be interleaved there. + */ + private volatile Runnable claimSnapshotHook; private volatile java.util.function.Consumer ptyTargetHook; private java.io.File workingDir; private volatile JediTermWidget terminalWidget; @@ -1231,8 +1237,17 @@ public void stopAndRename(String sessionName) { // 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. - Set held = new HashSet<>(preservedClaims); - held.addAll(claimedSessionIds(dir, configDir)); + // The exit waiter moves the claims between those two sets under + // the ownership lock, so both are read under it: reading them + // apart could take the old preserved set and the already + // cleared live set, hiding the conversation this tab held. + Runnable hook = claimSnapshotHook; + if (hook != null) hook.run(); + Set held; + synchronized (processOwnershipLock) { + held = new HashSet<>(preservedClaims); + held.addAll(claimedSessionIds(dir, configDir)); + } stopWriter(); sessionId = resolveIdentityWithPreservedClaims(held); } @@ -4931,6 +4946,15 @@ void simulatePtyKill(Runnable hook) { ptyKillHook = hook; } + /** + * Package-private for tests: runs just before the stop path reads the live + * and preserved session claims, so a process exit can be interleaved with + * that snapshot. + */ + void simulateClaimSnapshotHook(Runnable hook) { + claimSnapshotHook = hook; + } + /** * Package-private for tests: receives the PTY connector each plugin write * was bound to, or {@code null} when the write was dropped. Written to diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 94dcce0b..788f0734 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -8103,6 +8103,55 @@ void stopAndRename_usesResumeSessionId() throws Exception { "a newer unrelated session must not be renamed"); } + /** + * This tab's process quits on its own while Save & Switch is taking the + * snapshot of the claims it must carry into the final lookup. The exit + * waiter moves the claims from the live registry into the preserved set; + * the snapshot must observe the transition as a whole. Reading the two sets + * apart would miss the shared conversation this tab held, and the scan + * would then rename an unrelated eligible file even though the shared one + * is the newest. + */ + @Test + void aStopSnapshotsTheClaimsTheExitWaiterIsReleasing() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "shared", "shared"); + setMtime(dir.resolve("shared.jsonl"), START_MS + 12_000L); + writeSession(dir, "unrelated", "unrelated"); + setMtime(dir.resolve("unrelated.jsonl"), START_MS + 5_000L); + + // Another live tab resumed the same saved conversation. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, "shared", Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "shared", Set.of("shared", "unrelated")); + // The process quits exactly while the stop path snapshots the claims. + controller.simulateClaimSnapshotHook(controller::simulateProcessExit); + + try { + controller.stopAndRename("Shared Title"); + } finally { + tabB.stopProcess(); + } + + assertEquals("Shared Title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "shared").customTitle(), + "the conversation this tab held is renamed even after its process quit"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "unrelated").customTitle(), + "an unrelated file must not be renamed"); + } + @Test void stopAndRename_skipsUnchangedTitle() throws Exception { java.nio.file.Path config = tmpDir.resolve("config"); From ac0383a86521e751c5799415a75831461aa6e55c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 13:09:54 +0300 Subject: [PATCH 097/111] Kept a tentative sibling claim from hiding the other guess at a stop The rename ambiguity check excluded every file another live tab held, but a provisional claim is only another guess: two NEW tabs that submit identical text can each adopt a different file of the same prompt pair, and excluding the sibling's guess hid the very file that matched this tab's, letting the stop rename the wrong conversation. Only conversations another live tab has established now keep a competitor out of the check. The interrupted-provisional test also depended on the background identity scan not adopting a file before its explicit resolutions; a new seam waits for the scan queued by a send so the test is deterministic. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 26 +++++++- .../ClaudeSessionControllerTest.java | 63 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 40067b11..6f2defa5 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1316,8 +1316,12 @@ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path co String promptText = lastPromptText; long promptAt = promptSubmittedAt; if (promptText == null || promptText.isBlank() || promptAt <= 0) return false; - // A file holding the same prompt that no other live tab claims could - // equally be the one this run wrote: the evidence cannot tell them apart. + // 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 @@ -1325,7 +1329,7 @@ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path co // only files written after the input are candidates. long since = Math.max(processStartedAt, promptAt); if (externalInputAt > since) since = externalInputAt; - Set shared = claimedByOthers(dir, configDir); + Set shared = sessionsOwnedByOtherLiveTabs(dir, configDir); shared.add(sessionId); return findPromptBearingSession(dir, configDir, since, shared, null, promptAt, promptText) != null; @@ -4955,6 +4959,22 @@ void simulateClaimSnapshotHook(Runnable hook) { claimSnapshotHook = hook; } + /** + * Package-private for tests: waits until the background identity scan + * queued by the last prompt send has finished, so a test can decide + * exactly which session files that scan observes. + */ + void simulateAwaitIdentityScan() { + long deadline = System.nanoTime() + + java.util.concurrent.TimeUnit.SECONDS.toNanos(10); + while (identityScanPending.get()) { + if (System.nanoTime() > deadline) { + throw new IllegalStateException("identity scan did not finish"); + } + Thread.onSpinWait(); + } + } + /** * Package-private for tests: receives the PTY connector each plugin write * was bound to, or {@code null} when the write was dropped. Written to diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 788f0734..99a31836 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -7070,6 +7070,62 @@ void aStopDoesNotRenameWhenTheSiblingExitedBeforeTheStop() throws Exception { "the ambiguous candidate must not be renamed either"); } + /** + * 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 @@ -7203,6 +7259,11 @@ void aBorrowedProvisionalConversationDoesNotStayPreferred() throws Exception { // 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); @@ -7219,6 +7280,8 @@ void aBorrowedProvisionalConversationDoesNotStayPreferred() throws Exception { // 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(), From 807eb3222e65ad17dc666ad7c4c0438aa7cb10a3 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 13:34:29 +0300 Subject: [PATCH 098/111] Checked unknown-text guesses for ambiguity and kept MultiSelect menus open A prompt recalled from the TUI history or completed by Tab has no recorded text, so the resolver adopts the newest file with a user record written since the submission. That file is only a provisional guess: the tab's own file can appear later with the same kind of record, and the stop then writes the name into the wrong conversation. The ambiguity check now looks for such a competing file for unknown-text submissions too, excluding the launch conversation, which belongs to this tab by construction rather than being another tab's guess. A MULTI: checkbox answer is forwarded asynchronously: the worker waits between toggles and sends the submitting Right arrow last, and the TUI keeps the menu open meanwhile. The menu is no longer retired when the answer is accepted; the worker retires it after the arrow, so terminal input arriving in that window still counts as the menu's answer instead of becoming prompt evidence. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 35 +++++-- .../ClaudeSessionControllerTest.java | 97 +++++++++++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 6f2defa5..1a872044 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1315,7 +1315,7 @@ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path co if (claims != null && claims.owners.contains(this)) return false; String promptText = lastPromptText; long promptAt = promptSubmittedAt; - if (promptText == null || promptText.isBlank() || promptAt <= 0) return false; + if (promptAt <= 0) return false; // 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: @@ -1331,6 +1331,20 @@ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path co if (externalInputAt > since) since = externalInputAt; Set shared = sessionsOwnedByOtherLiveTabs(dir, configDir); shared.add(sessionId); + if (launchIdentityKnown && launchSessionId != null) { + // The launch conversation is this tab's own by construction, not + // another tab's guess: after a switch it may receive a record of + // its own without being the file this submission went to. + shared.add(launchSessionId); + } + if (promptText == null || promptText.isBlank()) { + // The submitted text is unknown (recalled from the TUI history or + // completed by Tab), so no file can be matched by its content: a + // file holding a user record of its own since the submission could + // equally be the one this run wrote. + return newestSessionWithUserMessageSince(dir, configDir, since, promptAt, shared) + != null; + } return findPromptBearingSession(dir, configDir, since, shared, null, promptAt, promptText) != null; } @@ -3518,11 +3532,13 @@ public void writePtyAnswer(String answer) { // 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 is the exception: the TUI keeps - // it open until the worker's Enter, and terminal input arriving in - // between answers that editor, so the worker retires it. + // An answer typed into the menu's editor — or toggled in a checkbox + // menu, whose submitting arrow 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. boolean typedAnswer = answer != null - && (answer.startsWith("TYPE:") || answer.startsWith("MULTI_TYPE:")); + && (answer.startsWith("TYPE:") || answer.startsWith("MULTI_TYPE:") + || answer.startsWith("MULTI:")); if (!typedAnswer) { synchronized (terminalInputLine) { completeTerminalMenu(openChoiceMenu()); @@ -3561,11 +3577,18 @@ public void writePtyAnswer(String answer) { 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 99a31836..bf1702e4 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -8254,6 +8254,103 @@ void stopAndRename_blankName_doesNothing() throws Exception { .findSession(work, config, "s1").customTitle()); } + /** + * A NEW tab submits a prompt whose text the plugin never sees (recalled + * from the TUI history or completed by Tab). The first file with a user + * record written since that submission is only a provisional guess: the + * tab's own file appears later with the same kind of record, and the + * evidence cannot tell which one this run wrote. The stop must not write + * the entered name into either of them. + */ + @Test + void aStopDoesNotRenameAnUnknownTextGuessWhileAnotherFileCompares() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + // The recalled prompt is submitted while no file of this run exists; + // the only file written since then is adopted provisionally. + controller.simulatePromptText(null); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "sibling prompt", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the only file written since the submission is provisionally adopted"); + controller.simulateAwaitIdentityScan(); + + // This tab's own file appears later: another ID, the same submission, + // and no text that could match it as this run's evidence. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "own prompt", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the provisional guess must not be renamed as this tab's"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing file must not be renamed as this tab's"); + } + + /** + * A checkbox answer is forwarded asynchronously: the worker waits between + * the toggles and sends the submitting Right arrow last, and the TUI keeps + * the menu open while it does. Terminal input arriving in that window + * answers the menu, so a line typed there is not a prompt and must not + * become prompt evidence. + */ + @Test + void aCheckboxAnswerKeepsTheMenuUntilTheSubmittingArrow() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select files", List.of( + checkbox("1", false), checkbox("2", false)), 0)); + + CountDownLatch firstToggle = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("1".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + firstToggle.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("MULTI:1"); + + assertTrue(firstToggle.await(5, TimeUnit.SECONDS), + "the worker forwarded the first toggle"); + + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the TUI is still in the checkbox menu: the typed line is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the menu is retired once the answer has been forwarded"); + } + // ------------------------------------------------------------------------- // No-op listener base class // ------------------------------------------------------------------------- From 3937e831ca2d1e6941d94315359d8d1b602ea4a1 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 15:48:03 +0300 Subject: [PATCH 099/111] Tracked forwarded menu focus and kept guesses ambiguous across commands A type-input answer activates its option with a digit and pauses before forwarding the text: the TUI cursor is in that option's editor by then, but the recorder still had it on the previously focused item, so a digit typed in the terminal during the pause selected another item and the rest of the line became prompt evidence. The worker now moves the tracked focus with each of its navigation writes and after the activating digit, as it already did for terminal digits. An ARROW: answer navigates the picker and submits with Enter last, so the picker stays open while the worker runs. It was retired as soon as the answer was accepted, which let terminal input in that window be recorded as a prompt; the menu is now retired by the worker after its Enter, like TYPE and MULTI answers. A file the run established for its own submission stayed excluded while another tab owned it: an explicit --resume of that file by a sibling tab therefore hid this tab's own conversation and let an unrelated transcript recording the same common prompt win the lookup. The remembered own file is kept eligible. The stop-time ambiguity check used the current prompt evidence, which a cancelled /resume picker replaces without identifying any writer (prompt submitted at the command time, no file written since). The identity scan now remembers the evidence each identity was adopted on, and the check judges the guess on that evidence, so the unresolved pair is still refused. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 117 +++++++++- .../ClaudeSessionControllerTest.java | 203 ++++++++++++++++++ 2 files changed, 309 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 1a872044..bfcecdce 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -272,6 +272,21 @@ private static final class SessionClaims { */ private volatile String ownSessionId; + /** + * The provisional identity this tab adopted and the submission evidence it + * was adopted on, or {@code null}/zero before one was published. A later + * submission from the terminal can replace the prompt evidence without + * resolving the conversation — a {@code /resume} command whose picker the + * user then cancels with Esc, for example — and the ambiguity check of + * {@link #stopAndRename(String)} must still judge the guess on the evidence + * that produced it, or the absence of newer records would read as proof + * that it is this tab's. + */ + private String guessSessionId; + private String guessPromptText; + private long guessPromptAt; + private long guessSince; + /** * Session IDs this tab established or guessed as its own before it stopped * its writer — or before its process exited on its own while the tab stayed @@ -1167,6 +1182,10 @@ public void stopProcess() { launchIdentityKnown = false; launchSessionId = null; ownSessionId = null; + guessSessionId = null; + guessPromptText = null; + guessPromptAt = 0L; + guessSince = 0L; promptSubmittedAt = 0; lastPromptText = null; externalInputAt = 0; @@ -1315,7 +1334,6 @@ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path co if (claims != null && claims.owners.contains(this)) return false; String promptText = lastPromptText; long promptAt = promptSubmittedAt; - if (promptAt <= 0) return false; // 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: @@ -1329,6 +1347,20 @@ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path co // 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; Set shared = sessionsOwnedByOtherLiveTabs(dir, configDir); shared.add(sessionId); if (launchIdentityKnown && launchSessionId != null) { @@ -1473,6 +1505,15 @@ boolean refreshSessionIdentity() { // session it established. Set ownExclude = new HashSet<>(exclude); ownExclude.addAll(otherOwned); + // The file this run established for its own submission stays this + // tab's own when another live tab later resumes it explicitly: that + // tab only borrows the conversation, and excluding it would hide + // the very file this process writes and let an unrelated transcript + // win. A merely provisional guess is not remembered here, so this + // cannot resurrect another tab's file. + if (ownSessionId != null) { + ownExclude.remove(ownSessionId); + } // The launch conversation is this tab's own even when another live // tab established it (both resumed the same saved file): a // temporary adoption of a sibling's conversation must not make the @@ -1867,7 +1908,7 @@ boolean refreshSessionIdentity() { } } return publishIdentityScanResult(generation, scanId, dir, configDir, newest, - ownEvidence); + ownEvidence, promptText, promptAt, Math.max(since, externalAt)); } /** @@ -2039,7 +2080,8 @@ private static boolean containsPrompt(java.io.File dir, java.nio.file.Path confi private boolean publishIdentityScanResult(long generation, long scanId, java.io.File dir, java.nio.file.Path configDir, - String newest, boolean ownEvidence) { + String newest, boolean ownEvidence, + String promptText, long promptAt, long since) { synchronized (processOwnershipLock) { if (generation != processGeneration) { return activeSessionId != null; @@ -2051,6 +2093,16 @@ private boolean publishIdentityScanResult(long generation, long scanId, if (ownEvidence && newest != null) { ownSessionId = newest; } + if (newest != null) { + // Remember the submission the identity was adopted on: terminal + // input can later lift the prompt evidence without resolving + // another conversation, and the stop-time ambiguity check must + // still use the adoption's evidence then. + guessSessionId = newest; + guessPromptText = promptText; + guessPromptAt = promptAt; + guessSince = since; + } if (newest != null && !newest.equals(activeSessionId)) { String previous = activeSessionId; activeSessionId = newest; @@ -2074,13 +2126,13 @@ long simulateBeginIdentityScan() { /** Package-private for tests: publishes a result scanned for an older process run. */ boolean simulatePublishStaleScanResult(long generation, String newest) { return publishIdentityScanResult(generation, simulateBeginIdentityScan(), - null, activeConfigDir, newest, false); + null, activeConfigDir, newest, false, null, 0L, 0L); } /** Package-private for tests: publishes a scan result with explicit ordering. */ boolean simulatePublishScanResult(long generation, long scanId, String newest) { return publishIdentityScanResult(generation, scanId, null, activeConfigDir, newest, - false); + false, null, 0L, 0L); } /** Package-private for tests: the identity inputs {@link #refreshSessionIdentity()} captures. */ @@ -2267,6 +2319,21 @@ private void trackMenuFocus(ChoiceMenuModel menu, int index) { 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. + */ + private void trackForwardedMenuFocus(ChoiceMenuModel menu, int index) { + if (menu == null) return; + int clamped = Math.max(0, Math.min(index, menu.options().size() - 1)); + synchronized (terminalInputLine) { + trackMenuFocus(menu, clamped); + } + } + /** * Returns whether {@code menu} is a checkbox menu, which submits on Right * (the answer protocol written by the menu panel) instead of a numbered @@ -3532,13 +3599,14 @@ public void writePtyAnswer(String answer) { // 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 — or toggled in a checkbox - // menu, whose submitting arrow 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. + // 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. boolean typedAnswer = answer != null && (answer.startsWith("TYPE:") || answer.startsWith("MULTI_TYPE:") - || answer.startsWith("MULTI:")); + || answer.startsWith("MULTI:") || answer.startsWith("ARROW:")); if (!typedAnswer) { synchronized (terminalInputLine) { completeTerminalMenu(openChoiceMenu()); @@ -3671,8 +3739,14 @@ 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++) { 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); @@ -3682,6 +3756,11 @@ public void writePtyAnswer(String answer) { writePty("\r"); } else { 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); writePty(text); Thread.sleep(200); @@ -3711,15 +3790,23 @@ public void writePtyAnswer(String answer) { 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) { writePty("\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 once the Enter is out. + synchronized (terminalInputLine) { + completeTerminalMenu(openChoiceMenu()); + } + 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++) { writePty(arrow); + trackForwardedMenuFocus(current, currentIdx + (idx + 1) * step); Thread.sleep(80); } writePty("\r"); @@ -3727,6 +3814,14 @@ public void writePtyAnswer(String answer) { 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); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index bf1702e4..3a7c53fb 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -8351,6 +8351,209 @@ void aCheckboxAnswerKeepsTheMenuUntilTheSubmittingArrow() throws Exception { "the menu is retired once the answer has been forwarded"); } + /** + * A type-input answer activates its option with a digit and pauses before + * forwarding the text. The TUI cursor is in that option's editor by then, + * so a line typed in the terminal during the pause is editor input — + * including its leading digit, which no longer selects another item. + */ + @Test + void aTypeAnswerMovesTheMenuFocusToItsEditor() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3", null, false, false, true)), + 0)); + + CountDownLatch typing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("auto answer".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + typing.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("TYPE:3:auto answer"); + assertTrue(typing.await(5, TimeUnit.SECONDS), + "the worker reached the option's editor"); + + assertNull(controller.simulateTerminalInput( + "1feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the cursor is in the option's editor: the typed line is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the menu is retired once the answer has been forwarded"); + } + + /** + * An arrow-key answer navigates the picker row by row and submits with + * Enter last. The picker stays open until that Enter, so a line typed in + * the terminal meanwhile is its search text or an answer, not a prompt. + */ + @Test + void anArrowAnswerKeepsThePickerUntilItsEnter() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an option", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + byte[] down = {0x1b, '[', 'B'}; + CountDownLatch moved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("ARROW:1"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker moved the picker cursor"); + + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the picker is still open: the typed line is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the picker is retired once the answer has been forwarded"); + } + + /** + * A NEW tab established {@code a} as its own file. Another tab later + * resumes {@code a} explicitly and becomes its owner; that must not hide + * the file from the tab that wrote it in favour of an unrelated transcript + * that merely recorded the same common prompt. + */ + @Test + void aLaterResumeByAnotherTabKeepsTheOwnFileEligible() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "continue", START_MS + 10_500L); + setMtime(dir.resolve("a.jsonl"), START_MS + 11_000L); + + assertEquals("a", controller.resolveActiveSessionId(), + "the file holding the submission is this tab's own"); + controller.simulateAwaitIdentityScan(); + + // Another tab's unrelated conversation recorded the same common text + // earlier; this tab's file stays the newer one. + writeSession(dir, "c", "c"); + appendUserPrompt(dir, "c", "continue", START_MS + 10_800L); + setMtime(dir.resolve("c.jsonl"), START_MS + 10_900L); + + // The sibling tab resumes this tab's conversation explicitly and + // becomes its owner. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS + 12_000L, true); + tabB.simulateLiveSession(config, "a", Set.of("a")); + + try { + assertEquals("a", controller.resolveActiveSessionId(), + "the resumed file is still the conversation this tab wrote"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "c").customTitle(), + "an unrelated transcript with the same text must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the guess stays ambiguous while the file is shared"); + } finally { + tabB.stopProcess(); + } + } + + /** + * A provisional guess must not become safe to rename just because a + * command cancelled in the terminal replaced the prompt evidence: the + * cancelled picker identified no writer, and the tab's own competing file + * still makes the two indistinguishable. + */ + @Test + void aCancelledPickerDoesNotMakeAGuessSafeToRename() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the only file written since the submission is provisionally adopted"); + controller.simulateAwaitIdentityScan(); + + // This tab's own file appears later and holds the same text. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "continue", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + controller.simulateAwaitIdentityScan(); + + // The user opens the /resume picker and cancels it with Esc: no + // conversation was resolved, but the prompt evidence is gone. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 20_000L); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 20_100L); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the guess must not be renamed after the cancelled picker"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing own file must not be renamed either"); + } + // ------------------------------------------------------------------------- // No-op listener base class // ------------------------------------------------------------------------- From 6fd917c9298f26de6fbe3cb56d7cd441d8fdcff1 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 16:15:47 +0300 Subject: [PATCH 100/111] Kept numeric menu answers open until Enter and preserved guess evidence An answer to a numbered picker whose item cannot be typed (10 and above) or that must navigate out of a focused text-input option is forwarded as arrow keys followed by Enter on a worker thread. The model entry was retired and cleared before the worker sent anything, so terminal input arriving during the pauses no longer classified as the menu's and could become prompt evidence for an unrelated transcript. writePtyAnswer now defers that retirement through answerKeepsMenuOpen(), covering both numeric arrow branches, and their workers retire the menu in a finally block. The identity adopted for a submitted prompt keeps its adoption evidence when a later refresh only returns the same file by modification time: assistant output written after a cancelled /resume touches the file without adding a submission, and replacing the guess's cutoff with the newer command would hide a competing file that still makes the two indistinguishable. publishIdentityScanResult() takes an evidenceBased flag and leaves the recorded guess untouched for an mtime-only refresh of the same identity. The stop-time ambiguity check now repeats the resolver's coarse-mtime relaxation: on filesystems that round modification times below the submission, this tab's own file can land in the same rounded bucket as the sibling it provisionally adopted, and the strict lookup missed it. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 133 ++++++++++++-- .../ClaudeSessionControllerTest.java | 167 ++++++++++++++++++ 2 files changed, 283 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index bfcecdce..f7477ac0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1369,16 +1369,45 @@ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path co // its own without being the file this submission went to. shared.add(launchSessionId); } - if (promptText == null || promptText.isBlank()) { + return hasCompetingSession(dir, configDir, since, promptAt, promptText, shared); + } + + /** + * Returns whether the store holds a conversation other than those in + * {@code exclude} that carries this submission: the same text when it is + * known, or a user record of its own when it is not. + * + *

    The lookup repeats the resolver's coarse-mtime relaxation: a file + * written right after the submission can round below it and still be the + * one this run wrote, so the second pass starts + * {@link #SESSION_MTIME_GRANULARITY_MS} earlier. The user record's own + * timestamp stays strict either way. + */ + private boolean hasCompetingSession(java.io.File dir, + java.nio.file.Path configDir, + long since, long promptAt, + String promptText, Set exclude) { + boolean unknownText = promptText == null || promptText.isBlank(); + boolean relaxed = since > SESSION_MTIME_GRANULARITY_MS; + long relaxedSince = since - SESSION_MTIME_GRANULARITY_MS; + if (unknownText) { // The submitted text is unknown (recalled from the TUI history or // completed by Tab), so no file can be matched by its content: a // file holding a user record of its own since the submission could // equally be the one this run wrote. - return newestSessionWithUserMessageSince(dir, configDir, since, promptAt, shared) - != null; + if (newestSessionWithUserMessageSince(dir, configDir, since, promptAt, exclude) + != null) { + return true; + } + return relaxed && newestSessionWithUserMessageSince(dir, configDir, + relaxedSince, promptAt, exclude) != null; + } + if (findPromptBearingSession(dir, configDir, since, exclude, null, + promptAt, promptText) != null) { + return true; } - return findPromptBearingSession(dir, configDir, since, shared, null, - promptAt, promptText) != null; + return relaxed && findPromptBearingSession(dir, configDir, relaxedSince, exclude, + null, promptAt, promptText) != null; } /** @@ -1484,6 +1513,12 @@ boolean refreshSessionIdentity() { } String newest = null; boolean ownEvidence = false; + // Whether the published identity was picked by submission evidence + // (a matching prompt or user record) rather than by file times alone. + // An mtime-only refresh of the same identity carries no new evidence + // about who wrote it, so it must not replace the evidence the guess + // was adopted on (see publishIdentityScanResult). + boolean evidenceBased = false; if (dir != null && configDir != null && since > 0) { // A conversation owned by a live tab (or by this tab) that already // contains the prompt submitted from here is the strongest evidence @@ -1602,6 +1637,7 @@ boolean refreshSessionIdentity() { } newest = findPromptBearingSession(dir, configDir, since, ownExclude, ownPreferred, promptAt, promptText); + evidenceBased = newest != null; if (ownPreferred != null && !ownPreferred.equals(newest) && since > SESSION_MTIME_GRANULARITY_MS) { // Coarse file times can round this tab's own file below the @@ -1617,6 +1653,7 @@ boolean refreshSessionIdentity() { ownPreferred, promptAt, promptText); if (relaxedOwn != null) { newest = relaxedOwn; + evidenceBased = true; } } if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { @@ -1628,6 +1665,7 @@ boolean refreshSessionIdentity() { newest = findPromptBearingSession(dir, configDir, since - SESSION_MTIME_GRANULARITY_MS, ownExclude, ownPreferred, promptAt, promptText); + evidenceBased = newest != null; } // A match in a file this tab only follows is not proof that // this run created it: it is another live tab's conversation, @@ -1659,6 +1697,7 @@ boolean refreshSessionIdentity() { } if (newer != null && !newer.equals(newest)) { newest = newer; + evidenceBased = true; // A conversation written later by someone else is not // the file this run created for its own submission. ownFileMatch = false; @@ -1693,6 +1732,7 @@ boolean refreshSessionIdentity() { && activeId.equals(launchSessionId) ? activeId : null; newest = findPromptBearingSession(dir, configDir, ordered, borrowedPreferred, promptAt, promptText); + evidenceBased = newest != null; } } if (promptAt > 0 && promptAt >= externalAt @@ -1756,10 +1796,12 @@ boolean refreshSessionIdentity() { if (ownCandidates == null) { newest = newestSessionWithUserMessageSince(dir, configDir, since, promptAt, ownExclude); + evidenceBased = newest != null; if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { newest = newestSessionWithUserMessageSince(dir, configDir, since - SESSION_MTIME_GRANULARITY_MS, promptAt, ownExclude); + evidenceBased = newest != null; } ownEvidence = newest != null; ownRecordMatch = newest != null; @@ -1793,6 +1835,7 @@ boolean refreshSessionIdentity() { > ClaudeSessionStore.sessionFileModified( dir.toPath(), configDir, newest)) { newest = switchedTo; + evidenceBased = true; // A conversation another live tab holds is not // this run's own file: it was resumed into, so // adopting it must not establish ownership. @@ -1803,10 +1846,12 @@ boolean refreshSessionIdentity() { } else if (!ownCandidates.isEmpty()) { newest = newestSharedSessionWrittenSince(dir, configDir, since, promptAt, ownCandidates, exclude); + evidenceBased = newest != null; if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { newest = newestSharedSessionWrittenSince(dir, configDir, since - SESSION_MTIME_GRANULARITY_MS, promptAt, ownCandidates, exclude); + evidenceBased = newest != null; } ownRecordMatch = newest != null; } @@ -1833,11 +1878,13 @@ boolean refreshSessionIdentity() { } newest = newestSharedSessionWrittenSince(dir, configDir, since, promptAt, shared, exclude); + evidenceBased = newest != null; if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { // Coarse file times can round the write below the submission; // the record's own timestamp still has to match. newest = newestSharedSessionWrittenSince(dir, configDir, since - SESSION_MTIME_GRANULARITY_MS, promptAt, shared, exclude); + evidenceBased = newest != null; } } if (switchAt > 0 && !ownRecordMatch) { @@ -1875,6 +1922,7 @@ boolean refreshSessionIdentity() { > ClaudeSessionStore.sessionFileModified(dir.toPath(), configDir, newest))) { newest = switched; + evidenceBased = true; // No other live tab holds the switched-to conversation, // so the record in it can only have come from this // process: it is the file this run resumed into. It @@ -1889,6 +1937,7 @@ boolean refreshSessionIdentity() { if (newest == null) { newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since, exclude); + evidenceBased = false; } if (newest == null && since > SESSION_MTIME_GRANULARITY_MS) { // Coarse filesystem timestamps can round a file written right @@ -1905,10 +1954,12 @@ boolean refreshSessionIdentity() { newest = ClaudeSessionStore.findLatestSessionIdWrittenSince( dir.toPath(), configDir, since - SESSION_MTIME_GRANULARITY_MS, relaxedExclude); + evidenceBased = false; } } return publishIdentityScanResult(generation, scanId, dir, configDir, newest, - ownEvidence, promptText, promptAt, Math.max(since, externalAt)); + ownEvidence, promptText, promptAt, Math.max(since, externalAt), + evidenceBased); } /** @@ -2081,7 +2132,8 @@ private boolean publishIdentityScanResult(long generation, long scanId, java.io.File dir, java.nio.file.Path configDir, String newest, boolean ownEvidence, - String promptText, long promptAt, long since) { + String promptText, long promptAt, long since, + boolean evidenceBased) { synchronized (processOwnershipLock) { if (generation != processGeneration) { return activeSessionId != null; @@ -2093,11 +2145,15 @@ private boolean publishIdentityScanResult(long generation, long scanId, if (ownEvidence && newest != null) { ownSessionId = newest; } - if (newest != null) { + if (newest != null && (evidenceBased || !newest.equals(guessSessionId))) { // Remember the submission the identity was adopted on: terminal // input can later lift the prompt evidence without resolving // another conversation, and the stop-time ambiguity check must - // still use the adoption's evidence then. + // still use the adoption's evidence then. A file-time-only + // refresh of the same identity carries no new submission: only + // an assistant-only append prompted it, and replacing the + // evidence with the newer command cutoff would hide the + // competing file. guessSessionId = newest; guessPromptText = promptText; guessPromptAt = promptAt; @@ -2126,13 +2182,13 @@ long simulateBeginIdentityScan() { /** Package-private for tests: publishes a result scanned for an older process run. */ boolean simulatePublishStaleScanResult(long generation, String newest) { return publishIdentityScanResult(generation, simulateBeginIdentityScan(), - null, activeConfigDir, newest, false, null, 0L, 0L); + null, activeConfigDir, newest, false, null, 0L, 0L, false); } /** Package-private for tests: publishes a scan result with explicit ordering. */ boolean simulatePublishScanResult(long generation, long scanId, String newest) { return publishIdentityScanResult(generation, scanId, null, activeConfigDir, newest, - false, null, 0L, 0L); + false, null, 0L, 0L, false); } /** Package-private for tests: the identity inputs {@link #refreshSessionIdentity()} captures. */ @@ -3577,6 +3633,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. * @@ -3604,10 +3691,7 @@ public void writePtyAnswer(String answer) { // 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. - boolean typedAnswer = answer != null - && (answer.startsWith("TYPE:") || answer.startsWith("MULTI_TYPE:") - || answer.startsWith("MULTI:") || answer.startsWith("ARROW:")); - if (!typedAnswer) { + if (!answerKeepsMenuOpen(answer)) { synchronized (terminalInputLine) { completeTerminalMenu(openChoiceMenu()); } @@ -3840,9 +3924,9 @@ 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) { writePty("\r"); + clearActiveChoiceMenu(); } else { byte[] arrow = delta < 0 ? new byte[]{0x1b, '[', 'A'} : new byte[]{0x1b, '[', 'B'}; Thread t = new Thread(() -> { @@ -3856,6 +3940,14 @@ public void writePtyAnswer(String answer) { 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); @@ -3866,7 +3958,6 @@ 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]"); @@ -3902,6 +3993,14 @@ private void sendArrowsThenEnter(int delta, String threadName) { 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); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 3a7c53fb..271e8723 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -1424,6 +1424,15 @@ private static void appendUserPrompt(java.nio.file.Path dir, String id, String t java.nio.file.StandardOpenOption.APPEND); } + /** Appends an assistant record, as the running process writes a reply. */ + private static void appendAssistantLine(java.nio.file.Path dir, String id, long millis) + throws java.io.IOException { + java.nio.file.Files.writeString(dir.resolve(id + ".jsonl"), + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"ok\"}" + + ",\"timestamp\":\"" + java.time.Instant.ofEpochMilli(millis) + "\"}\n", + java.nio.file.StandardOpenOption.APPEND); + } + private static void setMtime(java.nio.file.Path file, long millis) throws java.io.IOException { java.nio.file.Files.setLastModifiedTime(file, java.nio.file.attribute.FileTime.fromMillis(millis)); @@ -8554,6 +8563,164 @@ void aCancelledPickerDoesNotMakeAGuessSafeToRename() throws Exception { "the competing own file must not be renamed either"); } + /** + * An item numbered 10 or above cannot be typed into the picker: the answer + * is forwarded as Up/Down keys followed by Enter, with a pause between + * them. The menu stays open in the TUI until that Enter, so a line typed + * in the terminal meanwhile answers the menu and is not a prompt. + */ + @Test + void aTwoDigitAnswerKeepsTheMenuUntilItsEnter() throws Exception { + List options = new java.util.ArrayList<>(); + for (int i = 1; i <= 12; i++) { + options.add(new ChoiceMenuModel.Option("Item " + i, String.valueOf(i))); + } + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an item", options, 0, false, 1)); + + byte[] down = {0x1b, '[', 'B'}; + CountDownLatch moved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("10"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker started moving the picker cursor"); + + assertNull(controller.simulateTerminalInput( + "feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the picker is still open: the typed line is the menu's"); + assertNull(controller.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the picker is retired once the answer has been forwarded"); + } + + /** + * A guess adopted on the strength of a submitted prompt keeps that evidence + * when a later refresh only returns the same file by its modification time: + * assistant output written after a cancelled {@code /resume} adds no + * evidence about who wrote the file, and the competing conversation with + * an earlier user record must still keep the stop from renaming it. + */ + @Test + void anMtimeOnlyRefreshKeepsTheAdoptedGuessAmbiguous() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 11_000L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the only file written since the submission is provisionally adopted"); + controller.simulateAwaitIdentityScan(); + + // This tab's own file appears later and holds the same text. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "continue", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + controller.simulateAwaitIdentityScan(); + + // The user opens the /resume picker and cancels it with Esc: no + // conversation was resolved, but the prompt evidence is gone. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 20_000L); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 20_100L); + controller.simulateAwaitIdentityScan(); + + // The reply stored after the cancelled picker touches the tracked file + // again; the refresh can only return it by its modification time. + appendAssistantLine(dir, "b", START_MS + 21_000L); + setMtime(dir.resolve("b.jsonl"), START_MS + 21_000L); + assertEquals("b", controller.resolveActiveSessionId(), + "the modification time still names the tracked file"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "an mtime-only refresh must not make the guess safe to rename"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing own file must not be renamed either"); + } + + /** + * Filesystems may round modification times below the submission time, so + * the resolver adopts a sibling file written in the same second. When this + * tab's own file then appears in that same rounded bucket with the same + * prompt, the ambiguity check must search with the same relaxed cutoff — + * otherwise it misses the competitor and renames the sibling's file. + */ + @Test + void aRoundedMtimeCompetitorStillBlocksTheRename() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + + // The sibling's file was written just after the submission, but its + // modification time rounds down below it. + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "continue", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 9_999L); + + assertEquals("b", controller.resolveActiveSessionId(), + "the rounded file is still the one written since the submission"); + controller.simulateAwaitIdentityScan(); + + // This tab's own file lands in the same rounded bucket with the same + // text: the two are indistinguishable. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "continue", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 9_999L); + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the rounded sibling must not be renamed while both match"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing own file must not be renamed either"); + } + // ------------------------------------------------------------------------- // No-op listener base class // ------------------------------------------------------------------------- From a164ba60c2b1191c43a08f8c2e9b044904c145a3 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 16:33:42 +0300 Subject: [PATCH 101/111] Tracked forwarded menu focus on the instance the recorder queries The answer workers navigate the menu instance detectCurrentMenu() parses from the screen, while the terminal recorder classifies incoming input against the instance kept in the model. focusedMenuOption() accepts a tracked position only for the same instance, so with parsable screen lines the tracked focus never applied: a digit typed during a TYPE or ARROW worker's pause was classified against the stale default position, retired the menu and turned the line into prompt evidence for another transcript. trackForwardedMenuFocus() now resolves the option it was told about on the active instance before recording it, matching the label and answer of the option (optionIndexIn()), and leaves the reported position alone when the detection describes a different screen state. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 27 +++++++- .../ClaudeSessionControllerTest.java | 68 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index f7477ac0..44808b5c 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -2383,13 +2383,36 @@ private void trackMenuFocus(ChoiceMenuModel menu, int index) { * terminal while the write is still settling. */ private void trackForwardedMenuFocus(ChoiceMenuModel menu, int index) { - if (menu == null) return; + if (menu == null || menu.options().isEmpty()) return; int clamped = Math.max(0, Math.min(index, menu.options().size() - 1)); synchronized (terminalInputLine) { - trackMenuFocus(menu, clamped); + // 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 271e8723..53af21bc 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -8407,6 +8407,74 @@ void aTypeAnswerMovesTheMenuFocusToItsEditor() throws Exception { "the menu is retired once the answer has been forwarded"); } + /** + * The recorder classifies terminal input against the menu instance kept in + * the model, while the screen detector parses a fresh instance on every + * poll. An answer worker that recorded its forwarded cursor on the detected + * instance would never match, and a digit typed during the pause would + * retire the menu although the cursor sits in its text-input editor: the + * line typed there then becomes prompt evidence. + */ + @Test + void aTypeAnswerTracksFocusOnTheActiveMenuInstance() throws Exception { + List screen = java.nio.file.Files.readAllLines(java.nio.file.Path.of( + java.util.Objects.requireNonNull(getClass().getResource( + "/io/github/nbplugins/claudecodegui/process/screen-content-detector" + + "/detect-choice-menu/ultraplan-tell-claude.src.txt")) + .toURI())); + + ClaudeSessionModel m2 = new ClaudeSessionModel(); + ClaudeSessionController c2 = new ClaudeSessionController(m2, () -> screen); + CountDownLatch release = new CountDownLatch(1); + try { + m2.setActiveChoiceMenu(new ChoiceMenuModel( + "Claude has written up a plan and is ready to execute." + + " Would you like to proceed?", + List.of(new ChoiceMenuModel.Option("Yes, auto-accept edits", "1"), + new ChoiceMenuModel.Option("Yes, manually approve edits", "2"), + new ChoiceMenuModel.Option("No, refine with Ultraplan on" + + " Claude Code on the web", "3"), + new ChoiceMenuModel.Option("Tell Claude what to change", "4", + null, false, false, true)), + 0)); + + CountDownLatch typing = new CountDownLatch(1); + c2.simulatePtyWrite(bytes -> { + if ("auto answer".equals( + new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + typing.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + c2.writePtyAnswer("TYPE:4:auto answer"); + assertTrue(typing.await(5, TimeUnit.SECONDS), + "the worker reached the option's editor"); + + assertNull(c2.simulateTerminalInput( + "1feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L), + "the cursor is in the option's editor: the typed line is the menu's"); + assertNull(c2.simulateLastPromptText(), + "the menu's answer must not become prompt evidence"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (m2.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(m2.getActiveChoiceMenu(), + "the menu is retired once the answer has been forwarded"); + } finally { + release.countDown(); + c2.stopProcess(); + } + } + /** * An arrow-key answer navigates the picker row by row and submits with * Enter last. The picker stays open until that Enter, so a line typed in From a188dbb866cdef7273face39689e314c4d619af4 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 16:58:48 +0300 Subject: [PATCH 102/111] Forgot the terminal draft submitted by plugin model commands The /model commands are written at the TUI's cursor, so a draft typed in the terminal becomes part of the line the command submits. The write bypasses the terminal recorder, which therefore kept the draft and prefixed the next panel prompt with it, publishing text the conversation never received as exact prompt evidence. writePtySubmittingLine() resets the recorder and writes the command under the recorder's lock, so input arriving concurrently is neither lost nor misattributed. Both the custom-model command and the Enter that opens the model menu use it. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 24 +++++++++- .../ClaudeSessionControllerTest.java | 48 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 44808b5c..9b01dc7f 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -4315,7 +4315,7 @@ public void switchModel(int index) { String customId = customModelIds.get(index - standardModelCount); Thread t = new Thread(() -> { try { - writePty("/model " + customId + "\r"); + writePtySubmittingLine("/model " + customId + "\r"); Thread.sleep(500); } catch (IOException | InterruptedException ex) { LOG.warning("model switch failed: " + ex.getMessage()); @@ -5110,10 +5110,30 @@ private void openModelMenu() throws IOException, InterruptedException { Thread.sleep(200); writePty(PTY_ESC); Thread.sleep(200); - writePty("\r"); + // The Enter submits whatever draft the TUI holds together with the + // command (the write lands at its cursor), so the recorded draft is + // consumed and must not prefix the next prompt as evidence. + writePtySubmittingLine("\r"); Thread.sleep(200); } + /** + * Writes text that the TUI submits as a line on behalf of the plugin: any + * draft typed in the terminal is part of that line, so the recorder forgets + * it with the write. The reset and the write share the recorder's lock, so + * input arriving at the same moment is neither lost nor mistaken for a + * prefix of the next prompt. + * + * @param text the command line to submit + * @throws IOException if the write fails + */ + private void writePtySubmittingLine(String text) throws IOException { + synchronized (terminalInputLine) { + resetTerminalInputRecording(true); + writePty(text); + } + } + /** * Sends {@code bytes} to the PTY and transitions the model to * {@link SessionLifecycle#READY}. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 53af21bc..bae0a7c5 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -771,6 +771,54 @@ void switchModel_customIndex_noopWithoutConnector() throws Exception { assertDoesNotThrow(() -> c.switchModel(2)); } + /** + * A model switch writes the command at the TUI's cursor, so a draft typed + * in the terminal is submitted together with it. The write bypasses the + * terminal recorder, so unless the command retires the draft the next panel + * send is published as the draft plus its own text — evidence for a prompt + * the conversation never received. + */ + @Test + void aModelCommandSubmitsTheTerminalDraftAndForgetsIt() throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + controller.simulateConnector( + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p)); + + java.lang.reflect.Field customField = + ClaudeSessionController.class.getDeclaredField("customModelIds"); + customField.setAccessible(true); + customField.set(controller, java.util.List.of("openai/gpt-4o")); + java.lang.reflect.Field stdCount = + ClaudeSessionController.class.getDeclaredField("standardModelCount"); + stdCount.setAccessible(true); + stdCount.setInt(controller, 2); + + CountDownLatch written = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("/model openai/gpt-4o\r".equals( + new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + written.countDown(); + } + }); + + model.setLifecycle(SessionLifecycle.READY); + controller.simulateTerminalInput( + "draft".getBytes(java.nio.charset.StandardCharsets.UTF_8), START_MS); + + controller.switchModel(2); + assertTrue(written.await(5, TimeUnit.SECONDS), + "the model command was written"); + + assertEquals("next", controller.simulateGuiSend("next", START_MS + 200L), + "the draft was submitted by the model command, not by the next send"); + } finally { + p.destroyForcibly(); + } + } + /** * Verifies that {@code stopProcess} resets {@code customModelIds} and * {@code standardModelCount} to their initial values. From e805f4ac369453f61b7291837cfad258fc859a64 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 17:26:51 +0300 Subject: [PATCH 103/111] Fixed menu-answer and picker-switch attribution when naming a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recorder classifies terminal input against the menu focus it tracks, and three paths could still attribute the user's keys to the wrong conversation, so Save & Switch could rename a file this tab does not write. - The answer workers write the activating digit or the navigation arrow and only then record the focus it moved. PtyTtyConnector.write() releases the recorder lock before that record, so input arriving in the gap was classified against the previous option: a digit meant for a text editor dismissed the menu and its line leaked as prompt evidence. Both the write and the focus update now run inside one terminalInputLine section. - An Enter submitted from a recalled history line while a permission menu was published was treated as a submission of unknown text, clearing the exact prompt evidence and advancing its time. With a menu open the recalled text is the menu's search filter, so the recalled branch now requires !onMenu. - A /resume picker selection was recorded as a conversation switch whose destination is unknown, yet identityRemainsAmbiguous() still exempted the launch conversation from competition. When nothing submitted since the switch resolved a file, the stop renamed the launch conversation — the one the switch left behind. The launch identity is now reported ambiguous while the switch has not been followed by a submission. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 64 +++++--- .../ClaudeSessionControllerTest.java | 142 ++++++++++++++++++ 2 files changed, 188 insertions(+), 18 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 9b01dc7f..a86f6891 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1328,7 +1328,18 @@ public void stopAndRename(String sessionName) { */ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path configDir, String sessionId) { - if (sessionId == null || isLaunchIdentity(sessionId)) return false; + if (sessionId == null) return false; + if (isLaunchIdentity(sessionId) && conversationSwitchAt > 0 + && promptSubmittedAt <= conversationSwitchAt) { + // The last thing the terminal did was switch the conversation, and + // nothing submitted since has shown which file the process writes. + // The launch conversation is then the one the switch left behind: + // 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 the launch file either. + 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; @@ -2380,7 +2391,9 @@ private void trackMenuFocus(ChoiceMenuModel menu, int index) { * 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. + * 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; @@ -3848,12 +3861,17 @@ public void writePtyAnswer(String answer) { 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++) { - 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); + // 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); @@ -3862,12 +3880,14 @@ public void writePtyAnswer(String answer) { Thread.sleep(200); writePty("\r"); } else { - 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); + 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); writePty(text); Thread.sleep(200); @@ -3912,8 +3932,13 @@ public void writePtyAnswer(String answer) { Thread t = new Thread(() -> { try { for (int idx = 0; idx < Math.abs(delta); idx++) { - writePty(arrow); - trackForwardedMenuFocus(current, currentIdx + (idx + 1) * step); + // 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); } writePty("\r"); @@ -5558,12 +5583,15 @@ private String appendTerminalInput(byte[] bytes) { // input that follows. completeTerminalMenu(openMenu); } - if (unknownText) { + 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. + // 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 diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index bae0a7c5..a8bd21cd 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -771,6 +771,62 @@ void switchModel_customIndex_noopWithoutConnector() throws Exception { assertDoesNotThrow(() -> c.switchModel(2)); } + /** + * The worker activates a text-input option with a digit and only then + * records the new focus. Terminal input classified between the write and + * that record still sees the previous option, so a digit meant for the + * editor selects another item, retires the menu and leaks the line as + * prompt evidence. The write and the record must share the recorder's lock. + */ + @Test + void theActivatingWriteAndItsFocusUpdateAreAtomic() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", + List.of(new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3", null, + false, false, true)), + 0)); + + CountDownLatch inWrite = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("3".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + inWrite.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("TYPE:3:auto answer"); + assertTrue(inWrite.await(5, TimeUnit.SECONDS), + "the worker wrote the activating digit"); + + java.util.concurrent.atomic.AtomicReference submitted = + new java.util.concurrent.atomic.AtomicReference<>(); + CountDownLatch inputReturned = new CountDownLatch(1); + Thread writer = new Thread(() -> { + submitted.set(controller.simulateTerminalInput( + "1feedback\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L)); + inputReturned.countDown(); + }, "terminal-writer"); + writer.start(); + + // On the fixed code the writer waits for the recorder's lock; without + // the shared lock it runs inside the gap and returns here. + inputReturned.await(500, TimeUnit.MILLISECONDS); + release.countDown(); + writer.join(5_000L); + + assertNull(submitted.get(), + "the digit answers the option's editor, not another item"); + assertEquals(0, inputReturned.getCount(), + "the terminal writer must not be left blocked"); + } + /** * A model switch writes the command at the TUI's cursor, so a draft typed * in the terminal is submitted together with it. The write bypasses the @@ -5388,6 +5444,31 @@ void aMenuSelectionDigitIsNotRecordedAsPromptDraft() { "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 @@ -7127,6 +7208,67 @@ void aStopDoesNotRenameWhenTheSiblingExitedBeforeTheStop() throws Exception { "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"); + } + /** * 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; From d38267dd962104520d0059173e6d739717387c42 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 17:47:54 +0300 Subject: [PATCH 104/111] Fixed remaining stale-menu and stale-identity cases in session renaming The recorder's menu context and the tracked conversation could still outlive the event they described, so Save & Switch could either swallow a real prompt or name the conversation a /resume had left behind. - The ARROW-digit worker navigates out of a focused text editor so the digit answer selects an ordinary option, but it never recorded the forwarded focus: a digit typed in the terminal kept counting as editor text and the prompt after it was swallowed with the menu. Each arrow is now written and tracked inside one terminalInputLine section. - Every answer worker's submitting key (Enter, or Right for checkbox menus) was written outside the recorder lock and the menu retired only afterwards, so a prompt typed right after the key was classified against the dismissed menu. writePtyAnsweringMenu() writes the key and retires the menu in one lock section, and all workers use it. - The guard for an unresolved /resume switch only covered identities established at launch. A NEW tab that had resolved its conversation by prompt evidence was renamed after the picker left it behind: the guard now protects whatever identity is still tracked while the switch has not been followed by a submission. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 67 +++++-- .../ClaudeSessionControllerTest.java | 174 ++++++++++++++++++ 2 files changed, 221 insertions(+), 20 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index a86f6891..c9cf1ca4 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1329,14 +1329,14 @@ public void stopAndRename(String sessionName) { private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path configDir, String sessionId) { if (sessionId == null) return false; - if (isLaunchIdentity(sessionId) && conversationSwitchAt > 0 - && promptSubmittedAt <= conversationSwitchAt) { + if (conversationSwitchAt > 0 && promptSubmittedAt <= conversationSwitchAt) { // The last thing the terminal did was switch the conversation, and // nothing submitted since has shown which file the process writes. - // The launch conversation is then the one the switch left behind: - // 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 the launch file either. + // 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. return true; } if (isLaunchIdentity(sessionId)) return false; @@ -3760,7 +3760,7 @@ public void writePtyAnswer(String answer) { Thread.sleep(100); } // Right arrow to submit the selection - writePty(new byte[]{0x1b, '[', 'C'}); + writePtyAnsweringMenu(new byte[]{0x1b, '[', 'C'}); } catch (IOException ex) { LOG.warning("writePtyAnswer MULTI write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -3823,7 +3823,7 @@ public void writePtyAnswer(String answer) { writePty(new byte[]{0x1b, '[', 'A'}); // 6. Submit with right arrow Thread.sleep(100); - writePty(new byte[]{0x1b, '[', 'C'}); + writePtyAnsweringMenu(new byte[]{0x1b, '[', 'C'}); } catch (IOException ex) { LOG.warning("writePtyAnswer MULTI_TYPE write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -3878,7 +3878,7 @@ public void writePtyAnswer(String answer) { } writePty(text); Thread.sleep(200); - writePty("\r"); + writePtyAnsweringMenu("\r"); } else { synchronized (terminalInputLine) { writePty(digit); @@ -3891,7 +3891,7 @@ public void writePtyAnswer(String answer) { Thread.sleep(200); writePty(text); Thread.sleep(200); - writePty("\r"); + writePtyAnsweringMenu("\r"); } } catch (IOException ex) { LOG.warning("writePtyAnswer TYPE write failed: " + ex.getMessage()); @@ -3918,13 +3918,10 @@ public void writePtyAnswer(String answer) { int delta = targetIdx - currentIdx; LOG.fine("[PTY write] ARROW targetIdx=" + targetIdx + " currentIdx=" + currentIdx + " delta=" + delta); if (delta == 0) { - writePty("\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 once the Enter is out. - synchronized (terminalInputLine) { - completeTerminalMenu(openChoiceMenu()); - } + // 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'}; @@ -3941,7 +3938,7 @@ public void writePtyAnswer(String answer) { } Thread.sleep(80); } - writePty("\r"); + writePtyAnsweringMenu("\r"); } catch (IOException ex) { LOG.warning("writePtyAnswer ARROW write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -3973,17 +3970,28 @@ public void writePtyAnswer(String answer) { int delta = targetIdx - currentIdx; LOG.fine("[PTY write] ARROW-digit targetIdx=" + targetIdx + " currentIdx=" + currentIdx + " delta=" + delta); if (delta == 0) { - writePty("\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++) { - writePty(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); } - writePty("\r"); + writePtyAnsweringMenu("\r"); } catch (IOException ex) { LOG.warning("writePtyAnswer ARROW-digit write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -4036,7 +4044,7 @@ private void sendArrowsThenEnter(int delta, String threadName) { writePty(arrow); Thread.sleep(80); } - writePty("\r"); + writePtyAnsweringMenu("\r"); } catch (IOException ex) { LOG.warning(threadName + " write failed: " + ex.getMessage()); } catch (InterruptedException ie) { @@ -5159,6 +5167,25 @@ private void writePtySubmittingLine(String text) throws IOException { } } + /** + * Writes the key that answers a choice menu and retires that menu inside + * the recorder's lock. The write alone is not enough: between it and the + * retirement a terminal writer waiting for the lock could classify the + * prompt typed right after the key against the menu the TUI already + * dismissed, and swallow it as another menu answer. + */ + private void writePtyAnsweringMenu(byte[] bytes) throws IOException { + synchronized (terminalInputLine) { + writePty(bytes); + completeTerminalMenu(openChoiceMenu()); + } + } + + /** @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}. diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index a8bd21cd..e4082de9 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -827,6 +827,120 @@ void theActivatingWriteAndItsFocusUpdateAreAtomic() throws Exception { "the terminal writer must not be left blocked"); } + /** + * A panel answer naming an ordinary option while the menu's text editor + * has the focus is written as arrows, because a digit would be text for + * that editor. Once an arrow leaves the editor, a digit typed in the + * terminal selects an item again: without recording the forwarded focus + * the recorder keeps treating the digit as editor text and swallows the + * prompt typed after it. + */ + @Test + void anArrowAnswerLeavesTheTextEditorForTheRecorder() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 2)); + + CountDownLatch firstArrow = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicBoolean blocked = + new java.util.concurrent.atomic.AtomicBoolean(); + controller.simulatePtyWrite(bytes -> { + if (Arrays.equals(bytes, new byte[]{0x1b, '[', 'A'}) + && blocked.compareAndSet(false, true)) { + firstArrow.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("1"); + assertTrue(firstArrow.await(5, TimeUnit.SECONDS), + "the worker wrote the first arrow out of the editor"); + + AtomicReference submitted = new AtomicReference<>(); + CountDownLatch inputReturned = new CountDownLatch(1); + Thread writer = new Thread(() -> { + submitted.set(controller.simulateTerminalInput( + "1hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L)); + inputReturned.countDown(); + }, "terminal-writer"); + writer.start(); + + // On the fixed code the writer waits for the recorder's lock, held by + // the worker until it records the focus; without the record it runs + // inside the gap and returns here. + inputReturned.await(500, TimeUnit.MILLISECONDS); + release.countDown(); + writer.join(5_000L); + + assertEquals("hello", submitted.get(), + "the digit selects an option once the arrows left the editor"); + assertEquals(0, inputReturned.getCount(), + "the terminal writer must not be left blocked"); + } + + /** + * The key that answers a menu and the retirement of that menu must be + * atomic with respect to terminal input. A prompt typed right after the + * submitting key would otherwise still be classified against the menu the + * TUI already dismissed and swallowed as another menu answer. + */ + @Test + void theSubmittingKeyRetiresTheMenuBeforeTheNextPrompt() throws Exception { + List options = new ArrayList<>(); + for (int i = 1; i <= 12; i++) { + options.add(new ChoiceMenuModel.Option("Item " + i, String.valueOf(i))); + } + // The cursor already sits on item 10: the answer submits it with Enter. + model.setActiveChoiceMenu( + new ChoiceMenuModel("Pick an item", options, 0, false, 10)); + + CountDownLatch submitInWrite = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("\r".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + submitInWrite.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("10"); + assertTrue(submitInWrite.await(5, TimeUnit.SECONDS), + "the worker wrote the submitting Enter"); + + AtomicReference submitted = new AtomicReference<>(); + CountDownLatch inputReturned = new CountDownLatch(1); + Thread writer = new Thread(() -> { + submitted.set(controller.simulateTerminalInput( + "hello\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L)); + inputReturned.countDown(); + }, "terminal-writer"); + writer.start(); + + // On the fixed code the writer waits for the recorder's lock, held by + // the worker until the menu is retired; without it, the writer runs + // inside the gap and returns here. + inputReturned.await(500, TimeUnit.MILLISECONDS); + release.countDown(); + writer.join(5_000L); + + assertEquals("hello", submitted.get(), + "the prompt after the submitting key is not a menu answer"); + assertEquals(0, inputReturned.getCount(), + "the terminal writer must not be left blocked"); + } + /** * A model switch writes the command at the TUI's cursor, so a draft typed * in the terminal is submitted together with it. The write bypasses the @@ -7269,6 +7383,66 @@ void anUnresolvedPickerSwitchDoesNotRenameTheLaunchConversation() throws Excepti "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; From b15b4c598f5f0dcbf7ade767797718d5b1aacaea Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 11 Nov 2026 18:11:20 +0300 Subject: [PATCH 105/111] Dropped menu-owned text at submit and kept resumed competitors competing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the session name could still land in, or be composed from, the wrong conversation. - writePtyAnsweringMenu() retired the menu under the recorder lock but kept the line typed into it while the answer worker paused. A text option's answer or a picker's search filter then prefixed the next prompt sent from the panel, and the composed text was published as exact evidence of a prompt the conversation never received. The submit now discards the menu-owned line and cursor together with the retirement. - The rename-ambiguity check excluded every file another live tab had launched on. A launch or resume does not prove which process recorded this run's submission — it may be the file this run wrote — so the exclusion hid a competing file and let the stop rename the tab's guess. Only the tracked candidate and the launch conversation stay out of the competition now; every other file carrying the submission keeps the identity ambiguous. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 25 ++-- .../ClaudeSessionControllerTest.java | 117 ++++++++++++++++++ 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index c9cf1ca4..f11c5954 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -1372,7 +1372,15 @@ private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path co } } if (promptAt <= 0) return false; - Set shared = sessionsOwnedByOtherLiveTabs(dir, configDir); + // A file another live tab launched on is that tab's conversation, but + // the launch does not prove it recorded this submission: the tab may + // have resumed the very file this run wrote, and only files written + // since the submission are considered. A later resume of a competing + // file therefore leaves the earlier guess just as unresolved as + // before, so it must still compete; excluding it would authorize a + // rename the evidence cannot support. The tracked file is the + // candidate itself and stays out of the search. + Set shared = new HashSet<>(); shared.add(sessionId); if (launchIdentityKnown && launchSessionId != null) { // The launch conversation is this tab's own by construction, not @@ -5168,16 +5176,19 @@ private void writePtySubmittingLine(String text) throws IOException { } /** - * Writes the key that answers a choice menu and retires that menu inside - * the recorder's lock. The write alone is not enough: between it and the - * retirement a terminal writer waiting for the lock could classify the - * prompt typed right after the key against the menu the TUI already - * dismissed, and swallow it as another menu answer. + * Writes the key that answers a choice menu, retires that menu and drops + * the line recorded while it was open, all inside the recorder's lock. The + * write alone is not enough: between it and the retirement a terminal + * writer waiting for the lock could classify the prompt typed right after + * the key against the menu the TUI already dismissed, and swallow it as + * another menu answer. The line typed into the menu — its search filter or + * a text option's answer — is the menu's, not a draft of the next prompt: + * keeping it would prefix that prompt's evidence with it. */ private void writePtyAnsweringMenu(byte[] bytes) throws IOException { synchronized (terminalInputLine) { writePty(bytes); - completeTerminalMenu(openChoiceMenu()); + discardPickerSearchText(); } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index e4082de9..46213e8b 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -941,6 +941,123 @@ void theSubmittingKeyRetiresTheMenuBeforeTheNextPrompt() throws Exception { "the terminal writer must not be left blocked"); } + /** + * Text typed into an open menu — a search filter or a text option's + * answer — belongs to the menu, not to the next prompt. When an answer + * worker submits the menu, the recorded line must be dropped with it: + * otherwise the next prompt sent from the panel is composed with the + * menu's text and published as evidence the conversation never received. + */ + @Test + void aMenuAnswerIsNotDraftedIntoTheNextPanelPrompt() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("How was your experience?", List.of( + new ChoiceMenuModel.Option("Great", "1"), + new ChoiceMenuModel.Option("Bad", "2"), + new ChoiceMenuModel.Option("Type something.", "3")), 0)); + + CountDownLatch digitDone = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if ("3".equals(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))) { + digitDone.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("TYPE:3:auto"); + assertTrue(digitDone.await(5, TimeUnit.SECONDS), + "the worker activated the text option"); + + // Keys typed during the worker's pause answer the menu's editor. + controller.simulateTerminalInput( + "feedback".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 100L); + + release.countDown(); + long deadline = System.currentTimeMillis() + 5_000L; + while (model.getActiveChoiceMenu() != null && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + assertNull(model.getActiveChoiceMenu(), + "the worker retired the menu once its answer was forwarded"); + + assertEquals("hello", controller.simulateGuiSend("hello", START_MS + 300L), + "the menu's answer must not become the draft of the next prompt"); + } + + /** + * A NEW tab first guessed a sibling's {@code b}, then its own newer {@code a} + * appeared. A third tab resuming {@code a} makes the file a live tab's own, + * but that resume does not prove the earlier guess was correct: {@code a} + * still carries this run's submission, so it must keep competing and the + * stop must not write the name into the guessed file. + */ + @Test + void resumingTheCompetingFileDoesNotMakeTheGuessSafeToRename() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS, true); + tabB.simulateLiveSession(config, null, Set.of()); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, null, Set.of()); + + try { + controller.simulatePromptText("hello"); + controller.simulatePromptSubmittedAt(START_MS + 10_000L); + tabB.simulatePromptText("hello"); + tabB.simulatePromptSubmittedAt(START_MS + 10_000L); + + // The sibling's file is written first and this tab guesses it. + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "hello", START_MS + 10_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 10_600L); + assertEquals("b", controller.resolveActiveSessionId(), + "the only file written since the submission is provisionally adopted"); + controller.simulateAwaitIdentityScan(); + + // This tab's own newer file appears and holds the same text. + writeSession(dir, "a", "a"); + appendUserPrompt(dir, "a", "hello", START_MS + 12_000L); + setMtime(dir.resolve("a.jsonl"), START_MS + 12_500L); + assertEquals("b", controller.resolveActiveSessionId(), + "the tracked conversation is kept while the evidence cannot tell"); + + // A third tab resumes a, so the competitor now belongs to a live tab. + ClaudeSessionModel modelC = new ClaudeSessionModel(); + modelC.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabC = + new ClaudeSessionController(modelC, Collections::emptyList); + tabC.simulateProcessStart(START_MS + 20_000L, true); + tabC.simulateLiveSession(config, "a", Set.of("a", "b")); + try { + controller.stopAndRename("A-title"); + } finally { + tabC.stopProcess(); + } + } finally { + tabB.stopProcess(); + } + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the guessed conversation must not be renamed"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the competing file keeps the guess unresolved"); + } + /** * A model switch writes the command at the TUI's cursor, so a draft typed * in the terminal is submitted together with it. The write bypasses the From 57d5579d3bb5e07e3d69e9826efdb362498e4656 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 08:11:49 +0300 Subject: [PATCH 106/111] Fixed the unresolved-switch, early-answer and cancelled-picker identity gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require an evidence-based scan result from after a conversation switch before the stop-time guard lets a rename through: a prompt submitted after the switch proves nothing about where the picker went, since the destination may be a live sibling's file no evidence pass can adopt, and the same prompt also lands in the launch conversation when the selection never took effect. The guard now tracks the anchor of the scan result that identified a conversation written since the switch instead of comparing submission times. Publish the line the answer worker's Enter submits when terminal input already answered the choice menu during the worker's pause: the TUI's input line is a fresh prompt draft by then, so discarding it dropped the only evidence that follows the conversation the prompt was written to. Roll back a pending /resume switch when its picker is cancelled: opening the picker switches nothing, so the evidence the command replaced (conversation switch, prompt text and time, external-input time) is snapshotted at the submission and restored by every cancellation path — terminal Esc (consumed by the next write or at stop time), panel cancel, and the GUI send that consumes the pending Esc. A confirmed selection disarms the snapshot. Regression tests: aPromptBeforeTheDestinationRecordDoesNotUnblockTheRename, aSwitchResolvedByTheDestinationRecordStaysRenamable, theWorkersEnterAfterAnEarlyMenuAnswerIsRecorded, aCancelledPickerKeepsTheLaunchConversationRenamable, aCancelledPickerRestoresThePromptEvidence. Co-Authored-By: Claude Code --- pom.xml | 2 +- .../controller/ClaudeSessionController.java | 305 +++++++++++++++++- .../ClaudeSessionControllerTest.java | 234 ++++++++++++++ 3 files changed, 532 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index 5cae4baf..56f5e5b2 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.70-SNAPSHOT + 1.3.71-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index f11c5954..adada5b9 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -348,6 +348,34 @@ private static final class SessionClaims { */ private volatile long conversationSwitchAt; + /** + * Anchor time of the scan result that identified a conversation written + * after {@link #conversationSwitchAt}, or {@code 0} when no evidence-based + * result has been published since the switch. + * + *

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

    Guarded by {@link #terminalInputLine}. + */ + private boolean pickerSwitchPending; + private long switchBeforePicker; + private long promptBeforePicker; + private long externalBeforePicker; + private String textBeforePicker; + /** * Serializes prompt sends from the panel, from the text write through the * Enter and the publication of its evidence. Two sends pressed in quick @@ -407,6 +435,14 @@ private static final class SessionClaims { */ 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 @@ -587,6 +623,14 @@ private static final class SessionClaims { */ 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; @@ -1008,6 +1052,12 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr 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 : "", @@ -1190,6 +1240,12 @@ public void stopProcess() { 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); @@ -1244,6 +1300,19 @@ public void stopAndRename(String sessionName) { // 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 @@ -1329,14 +1398,17 @@ public void stopAndRename(String sessionName) { private boolean identityRemainsAmbiguous(java.io.File dir, java.nio.file.Path configDir, String sessionId) { if (sessionId == null) return false; - if (conversationSwitchAt > 0 && promptSubmittedAt <= conversationSwitchAt) { - // The last thing the terminal did was switch the conversation, and - // nothing submitted since has shown which file the process writes. + 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; @@ -2161,6 +2233,16 @@ private boolean publishIdentityScanResult(long generation, long scanId, return activeSessionId != null; } lastPublishedScanId = scanId; + if (newest != null && evidenceBased && conversationSwitchAt > 0 + && since >= conversationSwitchAt) { + // The result was adopted from a record written after the switch: + // the conversation on screen is known, so a later submission may + // authorize the rename. An mtime-only result says nothing about + // the destination and must not: a later prompt would otherwise + // read as proof that the switch was followed (see + // identityRemainsAmbiguous). + postSwitchResolvedAt = since; + } if (ownEvidence && newest != null) { ownSessionId = newest; } @@ -2297,6 +2379,7 @@ private void resetTerminalInputRecording(boolean keepCompletedMenu) { terminalInputPaste = false; terminalPasteAfterCR = false; terminalEscapeOnMenu = false; + terminalEscapeOnPicker = false; terminalMenuSelection = false; terminalSelectedMenu = null; terminalLineEntered = false; @@ -2317,6 +2400,7 @@ private void resetTerminalInputRecording(boolean keepCompletedMenu) { pendingGuiPromptExact = false; pendingGuiPromptSet = false; pendingGuiPromptSwitch = false; + pendingGuiPromptResume = false; if (!keepCompletedMenu) { terminalCompletedMenu = null; } @@ -2333,6 +2417,7 @@ 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. @@ -3162,6 +3247,7 @@ private void retireMenuAnsweredByGuiText(String text) { if (isConversationPicker(menu)) { // Picking an entry changes the conversation, and the digit says // nothing about which one was picked. + confirmPickerSwitch(); conversationSwitchAt = System.currentTimeMillis(); } } @@ -3190,6 +3276,12 @@ private String reconcileGuiSend(String text, long sentAt) { // 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; } @@ -3233,6 +3325,9 @@ private String reconcileGuiSend(String text, long sentAt) { // 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; } @@ -3271,6 +3366,13 @@ private void discardGuiSend(long generation) { 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 @@ -3281,6 +3383,12 @@ private void discardGuiSend(long generation) { 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; } } @@ -3389,6 +3497,12 @@ void completeGuiSend(java.util.function.LongSupplier clock, long generation) { // 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; } @@ -3418,6 +3532,7 @@ void completeGuiSend(java.util.function.LongSupplier clock, long generation) { // 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); @@ -3450,6 +3565,13 @@ void completeGuiSend(java.util.function.LongSupplier clock, long generation) { } 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) { @@ -3469,6 +3591,7 @@ void completeGuiSend(java.util.function.LongSupplier clock, long generation) { boolean pendingExact = pendingGuiPromptExact; boolean pendingSet = pendingGuiPromptSet; boolean pendingSwitch = pendingGuiPromptSwitch; + boolean pendingResume = pendingGuiPromptResume; boolean switched = false; resetTerminalInputRecording(true); if (alreadySubmitted && draftAfterEnter @@ -3553,6 +3676,13 @@ void completeGuiSend(java.util.function.LongSupplier clock, long generation) { // 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; } } @@ -3612,6 +3742,69 @@ private static boolean isConversationCommand(String text) { 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 text text submitted from the prompt panel or from the terminal + */ + private static boolean isResumeCommand(String text) { + return text != null && text.stripLeading().startsWith("/resume"); + } + + /** + * 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); + } + + /** + * {@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 @@ -3642,7 +3835,11 @@ static boolean isConversationPicker(ChoiceMenuModel menu) { void rememberChoiceMenuAnswer(String answer) { if (answer != null && answer.startsWith("ARROW:") && isConversationPicker(model.getActiveChoiceMenu())) { - conversationSwitchAt = System.currentTimeMillis(); + synchronized (terminalInputLine) { + // The picked entry is the switch the /resume was waiting for. + confirmPickerSwitch(); + conversationSwitchAt = System.currentTimeMillis(); + } } } @@ -3652,6 +3849,13 @@ && isConversationPicker(model.getActiveChoiceMenu())) { */ public void cancelPrompt() { if (connector == null) return; + synchronized (terminalInputLine) { + // Esc dismisses the choice menu on the screen: cancelling the + // /resume picker leaves the conversation where it was. + if (isConversationPicker(openChoiceMenu())) { + restorePickerSwitch(); + } + } try { sendCancelToPty(new byte[]{0x1b}); } catch (IOException ex) { @@ -3737,7 +3941,13 @@ public void writePtyAnswer(String answer) { // menu, so the worker retires it. if (!answerKeepsMenuOpen(answer)) { synchronized (terminalInputLine) { - completeTerminalMenu(openChoiceMenu()); + 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); @@ -5184,11 +5394,61 @@ private void writePtySubmittingLine(String text) throws IOException { * another menu answer. The line typed into the menu — its search filter or * a text option's answer — is the menu's, not a draft of the next prompt: * keeping it would prefix that prompt's evidence with it. + * + *

    The Enter is the exception when terminal input already answered the + * menu during the worker's pause: the TUI's input line is a fresh prompt + * draft by then, and this Enter submits it, so the line becomes the prompt + * evidence instead of being dropped. */ private void writePtyAnsweringMenu(byte[] bytes) throws IOException { + boolean submits = false; + String draft = null; + boolean draftExact = true; + long submittedAt = 0L; + boolean resumeDraft = 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'; + if (enterOnAnsweredMenu) { + draft = terminalInputLine.toString().trim(); + draftExact = !terminalHistoryRecall && !terminalCompletionEdited; + resumeDraft = draftExact && isResumeCommand(draft); + submittedAt = System.currentTimeMillis(); + submits = true; + } writePty(bytes); - discardPickerSearchText(); + if (enterOnAnsweredMenu) { + // The line is submitted: it must not prefix the next one, and a + // delayed panel Enter is no longer its submission. + resetTerminalInputRecording(true); + // The scan itself is queued below, outside the lock. + if (submits && resumeDraft) { + // A /resume typed after the early answer switches the + // conversation: it is not a prompt written to the current + // one, and the picker it opens can still be cancelled. + armPickerSwitch(); + conversationSwitchAt = submittedAt; + lastPromptText = null; + promptSubmittedAt = submittedAt; + } + } else { + discardPickerSearchText(); + } + } + if (submits) { + if (resumeDraft) { + scheduleIdentityRefresh(); + } else { + rememberSentPrompt(draft, submittedAt, draftExact); + } } } @@ -5369,6 +5629,11 @@ private String onTerminalInput(byte[] bytes, long now) { 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 @@ -5395,6 +5660,12 @@ private String onTerminalInput(byte[] bytes, long 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; } } @@ -5426,6 +5697,12 @@ private String onTerminalInput(byte[] bytes, long now) { // 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. + armPickerSwitch(); + } conversationSwitchAt = now; lastPromptText = null; } else { @@ -5492,6 +5769,9 @@ private String appendTerminalInput(byte[] bytes) { // 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; @@ -5499,8 +5779,11 @@ private String appendTerminalInput(byte[] bytes) { 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. - terminalEscapeOnMenu = openChoiceMenu() != null; + // prompt draft. Cancelling the picker leaves the conversation + // where it was, so the switch it was opened to make is undone. + ChoiceMenuModel escapedMenu = openChoiceMenu(); + terminalEscapeOnMenu = escapedMenu != null; + terminalEscapeOnPicker = isConversationPicker(escapedMenu); // 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; @@ -5520,6 +5803,12 @@ private String appendTerminalInput(byte[] bytes) { // 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 == '[') { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 46213e8b..0a70d5f4 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -9270,6 +9270,240 @@ void aRoundedMtimeCompetitorStillBlocksTheRename() throws Exception { "the competing own file must not be renamed either"); } + /** + * A launch-known tab switches to the conversation a live sibling writes; + * no evidence pass may adopt that file, so the tab keeps tracking the + * conversation the switch left behind. A prompt submitted afterwards moves + * the evidence cutoff past the switch without showing where the switch + * went, and it was also written into the launch conversation whenever the + * picker selection never took effect. Neither may authorize the rename: the + * entered name cannot be attributed to the conversation the process left. + */ + @Test + void aPromptBeforeTheDestinationRecordDoesNotUnblockTheRename() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + writeSession(dir, "b", "b"); + setMtime(dir.resolve("b.jsonl"), START_MS + 1_000L); + + // A sibling tab launched the conversation the picker will select. + ClaudeSessionModel modelB = new ClaudeSessionModel(); + modelB.setWorkingDirectory(work.toFile()); + ClaudeSessionController tabB = + new ClaudeSessionController(modelB, Collections::emptyList); + tabB.simulateProcessStart(START_MS - 500L, true); + tabB.simulateLiveSession(config, "b", Set.of("a", "b")); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a", "b")); + + try { + // The user opens /resume and picks the sibling's conversation. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 10_100L); + controller.simulateAwaitIdentityScan(); + + // A prompt submitted after the switch; no file records it, so the + // scan has nothing to follow the switch with. + controller.simulateTerminalInput( + "later\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 20_000L); + controller.simulateAwaitIdentityScan(); + assertEquals("a", controller.resolveActiveSessionId(), + "the launch conversation stays tracked while the destination is unknown"); + + controller.stopAndRename("A-title"); + + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "a submission after an unresolved switch must not rename the" + + " conversation the process left"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the live sibling's conversation must not be renamed either"); + } finally { + tabB.stopProcess(); + } + } + + /** + * The counterpart of the previous test: once a record written after the + * switch resolves the destination, the entered name goes there. The + * post-switch evidence must not block a rename the evidence does support. + */ + @Test + void aSwitchResolvedByTheDestinationRecordStaysRenamable() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + // The picker switches to a saved conversation no other tab holds. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 10_100L); + + // The destination records the prompt submitted after the switch. + controller.simulateTerminalInput( + "later\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 20_000L); + writeSession(dir, "b", "b"); + appendUserPrompt(dir, "b", "later", START_MS + 20_500L); + setMtime(dir.resolve("b.jsonl"), START_MS + 20_600L); + assertEquals("b", controller.resolveActiveSessionId(), + "the record written after the switch names the destination"); + controller.simulateAwaitIdentityScan(); + + controller.stopAndRename("Switched Name"); + + assertEquals("Switched Name", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "b").customTitle(), + "the resolved destination is the conversation the name belongs to"); + assertNull(io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the conversation left by the switch must not be renamed"); + } + + /** + * An arrow-key answer pauses between its navigation and its Enter. The user + * can answer the menu with Enter in the terminal during that pause and then + * type the next prompt; the worker's Enter submits that draft as a real + * prompt, so its text must become the prompt evidence — dropping it leaves + * the stop-time lookup unable to follow the conversation. + */ + @Test + void theWorkersEnterAfterAnEarlyMenuAnswerIsRecorded() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an option", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + byte[] down = {0x1b, '[', 'B'}; + java.util.concurrent.CountDownLatch moved = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("ARROW:1"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker paused before its submitting Enter"); + + // The user answers the menu in the terminal and types the next prompt, + // leaving it unsubmitted for the worker's Enter. + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.simulateTerminalInput( + "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 200L); + assertNull(controller.simulateLastPromptText(), + "nothing is submitted until the worker's Enter"); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (controller.simulateLastPromptText() == null + && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + + assertEquals("hello", controller.simulateLastPromptText(), + "the Enter submits the draft, so it is the prompt evidence"); + assertTrue(controller.simulatePromptSubmittedAtValue() > 0L, + "the submission time is recorded with it"); + } + + /** + * {@code /resume} only opens the picker: cancelling it with Esc leaves the + * conversation where it was, so the stop must still save the name into the + * launch conversation instead of treating the switch as unresolved. + */ + @Test + void aCancelledPickerKeepsTheLaunchConversationRenamable() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + // The user types /resume, its picker opens and Esc cancels it; then the + // stop dialog is opened without any further keystroke. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_100L); + + controller.stopAndRename("A-title"); + + assertEquals("A-title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "a cancelled picker never changed the conversation"); + } + + /** + * Cancelling the picker also puts back the prompt evidence the resume + * command replaced, so the conversation the tab was on before stays the + * submission the stop-time lookup follows. + */ + @Test + void aCancelledPickerRestoresThePromptEvidence() { + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 5_000L); + + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + assertEquals(START_MS + 10_000L, controller.simulatePromptSubmittedAtValue(), + "expected: the command submission is the newest evidence"); + + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_100L); + // A standalone Esc only becomes a cancellation once the next input + // shows it was not part of an escape sequence: a second Esc is that + // input and consumes it. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_150L); + + assertEquals("continue", controller.simulateLastPromptText(), + "the prompt submitted before the cancelled picker is the evidence again"); + assertEquals(START_MS + 5_000L, controller.simulatePromptSubmittedAtValue(), + "its submission time comes back with it"); + } + // ------------------------------------------------------------------------- // No-op listener base class // ------------------------------------------------------------------------- From 6e1e7b7ba140aa92aca368ac592fefbc9d6dce31 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 08:26:12 +0300 Subject: [PATCH 107/111] Fixed the external-input cutoff restored by a cancelled picker The /resume Enter moves externalInputAt before the command branch arms the picker switch, so the snapshot the cancellation restored held the command's own time instead of the pre-command one. After a cancel-restore the earlier prompt then looked stale to the stop-time lookup, which skipped its text evidence and the cutoff it judges competition by. Captured the cutoff inside the recorder lock before any input is applied and passed it to armPickerSwitch. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 16 ++++++++-- .../ClaudeSessionControllerTest.java | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index adada5b9..65087410 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -747,6 +747,11 @@ long simulatePromptSubmittedAtValue() { return promptSubmittedAt; } + /** Package-private for tests: the terminal-input cutoff of the prompt evidence. */ + long simulateExternalInputAtValue() { + return externalInputAt; + } + /** Package-private for tests: the prompt-evidence part of {@link #sendPrompt(String)}. */ void simulateSentPrompt(String text, long sentAt) { rememberSentPrompt(text, sentAt); @@ -5615,7 +5620,9 @@ private String onTerminalInput(byte[] bytes, long now) { boolean recalled; boolean menuSelection; ChoiceMenuModel selectedMenu; + long externalBeforeInput; synchronized (terminalInputLine) { + externalBeforeInput = externalInputAt; terminalLineEntered = false; terminalEnterOnEmptyLine = false; submitted = appendTerminalInput(bytes); @@ -5700,8 +5707,13 @@ private String onTerminalInput(byte[] bytes, long now) { 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. - armPickerSwitch(); + // 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; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 0a70d5f4..9cee0359 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -9504,6 +9504,35 @@ void aCancelledPickerRestoresThePromptEvidence() { "its submission time comes back with it"); } + /** + * The cancelled command also puts back the external-input cutoff: the + * {@code /resume} Enter is not a submission to the conversation, so it must + * not leave the cutoff at the command's own time, where the earlier prompt + * would look stale to the stop-time lookup. + */ + @Test + void aCancelledPickerRestoresTheExternalInputCutoff() { + controller.simulateExternalInput(START_MS + 4_000L); + controller.simulatePromptText("continue"); + controller.simulatePromptSubmittedAt(START_MS + 5_000L); + + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + assertEquals(START_MS + 10_000L, controller.simulateExternalInputAtValue(), + "expected: the submission itself is the newest terminal input"); + + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_100L); + // A standalone Esc becomes a cancellation once the next input shows it + // was not part of a sequence: a second Esc is that input. + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_150L); + + assertEquals(START_MS + 4_000L, controller.simulateExternalInputAtValue(), + "the cancelled command never reached the conversation"); + } + // ------------------------------------------------------------------------- // No-op listener base class // ------------------------------------------------------------------------- From d4b156eb7ed9cc1dea7bd2c3f9d0b6e8f709e3ca Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 08:36:29 +0300 Subject: [PATCH 108/111] Fixed the picker cancellation seen before the menu is published The screen detector publishes the /resume picker a few hundred milliseconds after the command, so an Esc pressed right away arrives while openChoiceMenu() is still null. The Esc was then not classified as a menu cancellation, the pending switch was never rolled back, and the stop treated the conversation as switched and dropped the entered name. An Esc with no published menu is now classified as the cancelled picker while a /resume admission is pending. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 9 +++-- .../ClaudeSessionControllerTest.java | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 65087410..3d77a116 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -5794,8 +5794,13 @@ private String appendTerminalInput(byte[] bytes) { // prompt draft. Cancelling the picker leaves the conversation // where it was, so the switch it was opened to make is undone. ChoiceMenuModel escapedMenu = openChoiceMenu(); - terminalEscapeOnMenu = escapedMenu != null; - terminalEscapeOnPicker = isConversationPicker(escapedMenu); + // 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; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 9cee0359..1d46f7ff 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -9473,6 +9473,41 @@ void aCancelledPickerKeepsTheLaunchConversationRenamable() throws Exception { "a cancelled picker never changed the conversation"); } + /** + * The screen poll publishes the picker a few hundred milliseconds after the + * command, so a user who presses Esc immediately cancels a menu the model + * has not seen yet. That Esc cancels the very picker the plugin is waiting + * on, so the switch must be rolled back just the same: otherwise the stop + * treats the conversation as switched and drops the name. + */ + @Test + void aPickerCancelledBeforeItIsPublishedKeepsTheLaunchConversationRenamable() throws Exception { + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + // /resume is submitted and Esc cancels the picker before the screen + // detector ever publishes it. + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + controller.simulateTerminalInput(new byte[]{0x1b}, START_MS + 10_100L); + + controller.stopAndRename("A-title"); + + assertEquals("A-title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the unpublished picker was cancelled, so nothing switched"); + } + /** * Cancelling the picker also puts back the prompt evidence the resume * command replaced, so the conversation the tab was on before stays the From ce5b01cfb070bd5a198f0808abf1174fa703bf61 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 08:45:29 +0300 Subject: [PATCH 109/111] Fixed the panel cancel of a picker not yet published The panel's Cancel button sends its own Esc to the PTY, bypassing the terminal recorder, so the pending /resume switch was only rolled back when the screen poll had already published the picker. The same unpublished-menu window therefore lost the entered name when the user cancelled from the panel instead of the keyboard. The cancel now treats a pending /resume admission as the picker too. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 7 ++- .../ClaudeSessionControllerTest.java | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 3d77a116..6da9f7a0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -3856,8 +3856,11 @@ public void cancelPrompt() { if (connector == null) return; synchronized (terminalInputLine) { // Esc dismisses the choice menu on the screen: cancelling the - // /resume picker leaves the conversation where it was. - if (isConversationPicker(openChoiceMenu())) { + // /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. + ChoiceMenuModel menu = openChoiceMenu(); + if (isConversationPicker(menu) || (menu == null && pickerSwitchPending)) { restorePickerSwitch(); } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 1d46f7ff..44d06c25 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -9508,6 +9508,49 @@ void aPickerCancelledBeforeItIsPublishedKeepsTheLaunchConversationRenamable() th "the unpublished picker was cancelled, so nothing switched"); } + /** + * The panel's Cancel button is the other way to dismiss the picker: like a + * terminal Esc it must roll the pending switch back even when the screen + * poll has not published the menu yet. + */ + @Test + void aPickerCancelledFromThePanelBeforeItIsPublishedKeepsTheLaunchConversationRenamable() + throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + controller.simulateConnector( + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p)); + + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + // The user presses the panel's Cancel before the picker is published. + controller.cancelPrompt(); + + controller.stopAndRename("A-title"); + + assertEquals("A-title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the cancelled picker never switched the conversation"); + } finally { + p.destroyForcibly(); + } + } + /** * Cancelling the picker also puts back the prompt evidence the resume * command replaced, so the conversation the tab was on before stays the From 10406ffa0cdfab25e066de41d391100b239c4b4c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 09:49:41 +0300 Subject: [PATCH 110/111] Fixed the command, menu-retirement and evidence-order gaps in the answer workers - writePtyAnsweringMenu: a /clear submitted by the worker's Enter is a conversation switch like /resume, so it is no longer recorded as prompt text; only /resume arms the cancellable picker. - The recovered prompt evidence is now published inside the recorder critical section together with its Enter and recorder reset, and only when it is not older than the evidence already recorded, so a terminal writer that submits concurrently keeps its newer text and time. - cancelPrompt: the plugin-written Esc bypasses the recorder, so a published menu is now retired and its search text discarded atomically with the cancelling write; otherwise the next panel send's Enter was read as a picker selection and its prompt evidence was dropped. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 77 +++++--- .../ClaudeSessionControllerTest.java | 165 ++++++++++++++++++ 2 files changed, 219 insertions(+), 23 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 6da9f7a0..6b388562 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -3855,14 +3855,23 @@ && isConversationPicker(model.getActiveChoiceMenu())) { public void cancelPrompt() { if (connector == null) return; synchronized (terminalInputLine) { + ChoiceMenuModel menu = openChoiceMenu(); // 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. - ChoiceMenuModel menu = openChoiceMenu(); if (isConversationPicker(menu) || (menu == null && pickerSwitchPending)) { restorePickerSwitch(); } + if (menu != null) { + // 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, and the + // search text typed into its box would prefix the next prompt. + // Both are retired here, atomically with the cancelling write. + discardPickerSearchText(); + } } try { sendCancelToPty(new byte[]{0x1b}); @@ -5409,11 +5418,7 @@ private void writePtySubmittingLine(String text) throws IOException { * evidence instead of being dropped. */ private void writePtyAnsweringMenu(byte[] bytes) throws IOException { - boolean submits = false; - String draft = null; - boolean draftExact = true; - long submittedAt = 0L; - boolean resumeDraft = false; + boolean publishes = false; synchronized (terminalInputLine) { ChoiceMenuModel menu = openChoiceMenu(); // The answer workers pause between the arrows and this key: terminal @@ -5425,41 +5430,67 @@ private void writePtyAnsweringMenu(byte[] bytes) throws IOException { 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; - resumeDraft = draftExact && isResumeCommand(draft); submittedAt = System.currentTimeMillis(); - submits = true; } writePty(bytes); - if (enterOnAnsweredMenu) { + 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 scan itself is queued below, outside the lock. - if (submits && resumeDraft) { - // A /resume typed after the early answer switches the - // conversation: it is not a prompt written to the current - // one, and the picker it opens can still be cancelled. - armPickerSwitch(); + // 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); } - } else { - discardPickerSearchText(); } } - if (submits) { - if (resumeDraft) { - scheduleIdentityRefresh(); - } else { - rememberSentPrompt(draft, submittedAt, draftExact); - } + 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)); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 44d06c25..02778cba 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -9438,6 +9438,121 @@ void theWorkersEnterAfterAnEarlyMenuAnswerIsRecorded() throws Exception { "the submission time is recorded with it"); } + /** + * The early-answer recovery also submits whatever command the user typed + * after answering the menu. {@code /clear} switches the conversation just + * like {@code /resume}, so its line is no prompt's text and the switch has + * to advance — recording it as an ordinary submission would leave the scan + * following the conversation the tab is no longer on. + */ + @Test + void theWorkersEnterSubmittingClearIsAConversationSwitch() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an option", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + byte[] down = {0x1b, '[', 'B'}; + java.util.concurrent.CountDownLatch moved = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + controller.writePtyAnswer("ARROW:1"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker paused before its submitting Enter"); + + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.simulateTerminalInput( + "/clear".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 200L); + + release.countDown(); + long deadline = System.currentTimeMillis() + 3_000L; + while (controller.simulatePromptSubmittedAtValue() == 0L + && System.currentTimeMillis() < deadline) { + Thread.sleep(20L); + } + + assertNull(controller.simulateLastPromptText(), + "a command switches the conversation, it is not prompt evidence"); + assertTrue(controller.simulatePromptSubmittedAtValue() > 0L, + "the submission is recorded for the scan"); + } + + /** + * The worker's Enter publishes its evidence inside the recorder lock: a + * terminal writer that submitted after this one must keep its newer text + * and time, or the stop-time lookup follows a prompt that is no longer the + * last one the conversation received. + */ + @Test + void theWorkersEnterDoesNotReplaceNewerPromptEvidence() throws Exception { + model.setActiveChoiceMenu(new ChoiceMenuModel("Select an option", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0"), + new ChoiceMenuModel.Option("Two", "ARROW:1")), 0)); + + byte[] down = {0x1b, '[', 'B'}; + byte[] enterKey = {'\r'}; + java.util.concurrent.CountDownLatch moved = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch entered = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + controller.simulatePtyWrite(bytes -> { + if (java.util.Arrays.equals(bytes, down)) { + moved.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } else if (java.util.Arrays.equals(bytes, enterKey)) { + entered.countDown(); + } + }); + + controller.writePtyAnswer("ARROW:1"); + assertTrue(moved.await(5, TimeUnit.SECONDS), + "the worker paused before its submitting Enter"); + + controller.simulateTerminalInput(new byte[]{'\r'}, START_MS + 100L); + controller.simulateTerminalInput( + "older".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 200L); + // A terminal writer submits a newer prompt before this older draft is + // published; its timestamp is later than the worker's capture. + long newerAt = System.currentTimeMillis() + 60_000L; + controller.simulatePromptText("newer"); + controller.simulatePromptSubmittedAt(newerAt); + + release.countDown(); + assertTrue(entered.await(5, TimeUnit.SECONDS), + "the worker submits the line"); + // The worker publishes right after writing its Enter: give it a moment + // to do so, then check that it left the newer evidence alone. + long deadline = System.currentTimeMillis() + 1_000L; + while (controller.simulatePromptSubmittedAtValue() == newerAt + && System.currentTimeMillis() < deadline) { + Thread.sleep(10L); + } + + assertEquals("newer", controller.simulateLastPromptText(), + "the newer submission keeps the prompt evidence"); + assertEquals(newerAt, controller.simulatePromptSubmittedAtValue(), + "and its submission time comes back with it"); + } + /** * {@code /resume} only opens the picker: cancelling it with Esc leaves the * conversation where it was, so the stop must still save the name into the @@ -9551,6 +9666,56 @@ void aPickerCancelledFromThePanelBeforeItIsPublishedKeepsTheLaunchConversationRe } } + /** + * Cancelling a published picker from the panel must retire it at once: the + * plugin writes the Esc itself, so the screen poll would keep reporting the + * menu. A panel prompt sent before that poll then has its Enter mistaken + * for a picker selection — the evidence is dropped and the switch is + * recorded again, although the conversation never changed. + */ + @Test + void aPickerCancelledFromThePanelIsRetiredBeforeTheNextSend() throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse( + System.getProperty("os.name", "").toLowerCase().contains("win")); + com.pty4j.PtyProcess p = startEchoPty(); + try { + controller.simulateConnector( + new io.github.nbplugins.claudecodegui.process.PtyTtyConnector(p)); + + java.nio.file.Path config = tmpDir.resolve("config"); + java.nio.file.Path work = tmpDir.resolve("project"); + java.nio.file.Path dir = sessionDir(config, work); + + writeSession(dir, "a", "a"); + setMtime(dir.resolve("a.jsonl"), START_MS - 5_000L); + + model.setWorkingDirectory(work.toFile()); + controller.simulateProcessStart(START_MS, true); + controller.simulateLiveSession(config, "a", Set.of("a")); + + controller.simulateTerminalInput( + "/resume\r".getBytes(java.nio.charset.StandardCharsets.UTF_8), + START_MS + 10_000L); + model.setActiveChoiceMenu(new ChoiceMenuModel("Resume Session (1 of 1)", List.of( + new ChoiceMenuModel.Option("One", "ARROW:0")), 0)); + + // The user cancels the published picker from the panel and sends + // the next prompt before the screen poll clears the menu. + controller.cancelPrompt(); + controller.simulateGuiSend("hello", START_MS + 20_000L); + controller.completeGuiSend(); + + controller.stopAndRename("A-title"); + + assertEquals("A-title", + io.github.nbplugins.claudecodegui.process.ClaudeSessionStore + .findSession(work, config, "a").customTitle(), + "the cancelled picker never switched the conversation"); + } finally { + p.destroyForcibly(); + } + } + /** * Cancelling the picker also puts back the prompt evidence the resume * command replaced, so the conversation the tab was on before stays the From 8d51b3f6522a3ae0d755c117b9216dccd2bf944a Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 10:33:17 +0300 Subject: [PATCH 111/111] Fixed the draft kept after cancelling a picker not yet published The panel cancel discards the picker's search text only when the menu reached the model. When the cancel lands before the screen poll publishes the picker, the digits typed into it stay in the recorder and would prefix the next prompt, so a pending /resume admission now drops the draft too. Co-Authored-By: Claude Code --- .../controller/ClaudeSessionController.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 6b388562..955dd4c3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -3856,20 +3856,21 @@ 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) || (menu == null && pickerSwitchPending)) { + if (isConversationPicker(menu) || pendingPicker) { restorePickerSwitch(); } - if (menu != null) { + 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, and the - // search text typed into its box would prefix the next prompt. - // Both are retired here, atomically with the cancelling write. + // 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(); } }