8000 fix(terminal): let Ctrl+Shift+R leave a dead program's alternate screen again by jpstratman · Pull Request #359 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ docs/ Planning docs

**Components** (in `components/`):
- `SplitPane/` — PaneWrapper, SplitContainer, SplitDivider, SurfaceTabBar. `surface-label.ts` is the one tab-caption chain (customTitle → agent spawn label → OSC window title → defaultTitle → cwd → shell). `defaultTitle` is a TEMPLATE’s name — a quick-launch profile, a manifest pane `title` (#291) — which used to be written into `customTitle` and so outranked the program’s own title forever; `customTitle` is reserved for names a human chose, and `use-osc-title.ts` is the single reader of that title so the tab bar and the split-drag preview cannot apply the pref gate differently
- `Terminal/` — TerminalPane, FindBar, CopyMode, NotificationRing, PinnedPrompt, PromptOutline, NewOutputPill (the three prompt-log OVERLAYS, #207 — all `position: absolute` over the xterm container, never flow siblings: a flow sibling is pushed out of the container's `height:100%` box WITHOUT firing the ResizeObserver, so the PTY keeps its old row count and the bottom rows hide underneath, which is issue #82). Also `PromptsPane` — the outline as a `prompts` SURFACE rather than an overlay, and the one file here the #82 warning does NOT apply to, because it owns its whole box instead of floating over somebody else's. It is the same prompt log seen from elsewhere in the split tree, so unlike the overlay it has to be TOLD which terminal it lists: `promptSourceSurface` (the last terminal to hold focus, written by App.tsx) unless a `promptPaneLocks` entry pins it. Focus landing on a non-terminal deliberately does not update that, or clicking into the panel would blank the list the user just clicked on
- `Terminal/` — TerminalPane, FindBar, CopyMode, NotificationRing, PinnedPrompt, PromptOutline, NewOutputPill (the three prompt-log OVERLAYS, #207 — all `position: absolute` over the xterm container, never flow siblings: a flow sibling is pushed out of the container's `height:100%` box WITHOUT firing the ResizeObserver, so the PTY keeps its old row count and the bottom rows hide underneath, which is issue #82). `ResetNotice` (#357) floats the same way: Reset terminal state's "kept the alternate screen, press again" notice, a `role="status"` region that stays mounted so its text is announced. Also `PromptsPane` — the outline as a `prompts` SURFACE rather than an overlay, and the one file here the #82 warning does NOT apply to, because it owns its whole box instead of floating over somebody else's. It is the same prompt log seen from elsewhere in the split tree, so unlike the overlay it has to be TOLD which terminal it lists: `promptSourceSurface` (the last terminal to hold focus, written by App.tsx) unless a `promptPaneLocks` entry pins it. Focus landing on a non-terminal deliberately does not update that, or clicking into the panel would blank the list the user just clicked on
- `Browser/` — BrowserPane, AddressBar, AgentBrowserSetup (the `not-installed` vs `no-dashboard` cards — two genuinely different situations, never one card: the second means the agent browser works fine and only its optional viewer is missing). `utils/browser-start-page.ts` holds the one rule about what a surface opens on when nobody said (#232). A browser surface defaults to **blank**, never to a page on the internet: shipping `https://github.com/amirlehmam/wmux` as `BrowserPane`'s default parameter meant the panel opened the project's own repo on every launch (`openOnStartup` is true by default) — and then the panel's `onUrlChange`, which fires for the INITIAL load too, wrote that default into `workspace.browserUrl` as if a human had picked it. Confirmed on a real install: two workspaces carrying a `browserUrl` nobody typed. GitHub puts "Issues" one click away, so one click pinned wmux's own issue tracker as the start page forever. The read-time filter applies to `workspace.browserUrl` ONLY — that is the one URL wmux opens without being asked; a split-tree browser tab's `surface.url` is written directly by `openInWmuxBrowser` on a link click, and filtering there would blank a pane opened on wmux's issue tracker on purpose. `browserPrefs.defaultUrl` is never filtered: a configured start page is a choice, a remembered one is an inference. The blank state is a themed overlay rather than a bare `about:blank`, which paints WHITE whatever the app theme is
- `Sidebar/` — Sidebar, WorkspaceRow, SessionMenu, SidebarResizeHandle. `workspace-status.ts` holds the THREE chains that must agree about one row — the status words, the status colour, and the state dot — and they have drifted apart twice, which is why they sit in one file beside each other rather than inside the component. The rule that keeps them honest is `mergedAgentSignals` (#235): the agent roster's counts **join** the older hook/observer signals rather than outranking them, so the priority chain stays one chain. Both halves of that matter. A rollup ranked above the tool label coarsens "Reading file…" into "Running…" every time the two agree, and `unknown` — which means "an agent is here and has not said what it is doing" — must fall THROUGH to the heuristics (#128), never over them: as its own priority level it printed the word "Unknown" on a row whose shell knew it was running, and blanked the dot. `unknown` therefore contributes to no count at all, and the invariant is pinned as a PARITY (a silent agent renders byte-identically to no agent) rather than as an expected string — anything a silent agent changes, it changed by overriding a finer signal it knows nothing about. `workspace-label.ts` is the row's NAME: a title wmux picked (`titleIsDefault` — `Workspace N`, a layout instance, the tab-derived name) gives way to the OSC title of the first pane in tree order whose shown tab is a titled terminal, behind `terminalPrefs.oscTitleWorkspaces`; any rename clears the flag, and a session saved before it existed keeps its names fixed. Display only — `list-workspaces`/`current-workspace` still answer the stored title
- `Titlebar/` — Titlebar, NotificationBell, NotificationPanel
Expand Down Expand Up @@ -288,6 +288,11 @@ listing prompts as jump marks are four consumers of ONE fact: where a user promp
starts. So there is one producer (`utils/prompt-log.ts`) and four independent,
individually-toggleable views — not four detectors.

The same parse has one non-view consumer: `handlePromptMark` reports A, C and D marks
to `utils/alt-screen-owner.ts` (#357), which uses those seen on the alternate buffer to
decide whether Reset terminal state may leave it. Reported whatever the prompt prefs say,
and through this parse — never a second OSC 133 handler.

Two sources feed it, **ranked and never merged**, the shape `ssh-detect.ts` uses:

| Source | Carries | Where |
Expand Down
10 changes: 8 additions & 2 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
import { buildSurfaceDragPreview } from './components/SplitPane/surface-drag-preview';
import { forgetSurfaceTitle, surfaceTerminalRegistry } from './hooks/useTerminal';
import { forgetSurface as forgetPromptLog, hookEventSurface, recordAgentPrompt } from './utils/prompt-log';
import { forgetAlternateScreen, RESET_TERMINAL_LEAVE_ALT_ACTION } from './utils/alt-screen-owner';
import { SURFACE_CLOSED_EVENT } from './store/pty-teardown';
import { installRemoteRendererHandler } from './utils/remote-snapshot';
import { formatRemoteNotice } from './utils/remote-notice';
Expand Down Expand Up @@ -1003,6 +1004,8 @@ export default function App() {
// store a moment after the store entry was dropped.
forgetSurfaceTitle(surfaceId);
useStore.getState().clearOscTitle(surfaceId);
// Reset terminal state's record of the alternate screen (#357).
forgetAlternateScreen(surfaceId);
};
document.addEventListener(SURFACE_CLOSED_EVENT, handler);
return () => document.removeEventListener(SURFACE_CLOSED_EVENT, handler);
Expand Down Expand Up @@ -1418,9 +1421,12 @@ export default function App() {
const surface = leaf?.surfaces[leaf.activeSurfaceIndex];
const terminalSurfaceId = surface?.type === 'terminal' ? surface.id : null;

if (action === 'resetTerminal') {
if (action === 'resetTerminal' || action === RESET_TERMINAL_LEAVE_ALT_ACTION) {
if (terminalSurfaceId) {
document.dispatchEvent(new CustomEvent('wmux:reset-terminal', { detail: { surfaceId: terminalSurfaceId } }));
// The palette's second entry skips the keep-then-press-again step
// (#357): choosing it by name already says "leave it anyway".
const leaveAlternateScreen = action === RESET_TERMINAL_LEAVE_ALT_ACTION;
document.dispatchEvent(new CustomEvent('wmux:reset-terminal', { detail: { surfaceId: terminalSurfaceId, leaveAlternateScreen } }));
}
} else if (action === 'togglePromptOutline') {
togglePromptOutlineFor(
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/components/CommandPalette/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SurfaceId } from '../../../shared/types';
import { ShortcutAction, ShortcutBinding } from '../../store/settings-slice';
import { actionLabel } from '../Settings/KeyboardSettings';
import { useT } from '../../i18n';
import { RESET_TERMINAL_LEAVE_ALT_ACTION } from '../../utils/alt-screen-owner';
import '../../styles/command-palette.css';

interface CommandPaletteProps {
Expand Down Expand Up @@ -146,6 +147,17 @@ export default function CommandPalette({ onClose, onAction }: CommandPaletteProp
},
});

// Reset terminal state keeps the alternate screen while a program may be
// drawing on it (#347); this entry leaves it anyway, for a pane whose shell
// emits no prompt marks (#357). Routed through onAction because the focused
// terminal is App's to resolve, and App handles this id explicitly.
items.push({
id: 'command:reset-terminal-leave-alt',
label: t('palette.resetTerminalLeaveAlt', 'Reset terminal state (leave alternate screen)'),
category: t('palette.category.commands'),
action: () => onAction(RESET_TERMINAL_LEAVE_ALT_ACTION),
});

// Category: Layouts — saved pane arrangements
items.push({
id: 'command:save-current-layout',
Expand Down
52 changes: 52 additions & 0 deletions src/renderer/components/Terminal/ResetNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { useStore } from '../../store';
import { useT } from '../../i18n';
import { formatBinding } from '../Settings/KeyboardSettings';
import { RESET_NOTICE_EVENT, type ResetNoticeDetail } from '../../utils/alt-screen-owner';

/**
* "Kept the alternate screen — press again to leave it" (#357), shown when a
* reset kept it and for as long as a second press would force it.
*
* An overlay rather than a `[wmux] …` line written to the terminal: the case it
* appears in is the one where a program may still be drawing on that screen,
* and a line written there would land inside the program's frame and move its
* cursor. Non-modal and never hit-testable, like the copy-mode indicator.
*/
export default function ResetNotice({ surfaceId }: { surfaceId: string }) {
const t = useT();
const binding = useStore((s) => s.shortcuts.resetTerminal);
const [until, setUntil] = useState<number | null>(null);

useEffect(() => {
const Event) => {
const detail = (event as CustomEvent<ResetNoticeDetail>).detail;
if (!detail || detail.surfaceId !== surfaceId) return;
setUntil(detail.until);
};
document.addEventListener(RESET_NOTICE_EVENT, onNotice);
return () => document.removeEventListener(RESET_NOTICE_EVENT, onNotice);
}, [surfaceId]);

useEffect(() => {
if (until === null) return;
const timer = setTimeout(() => setUntil(null), Math.max(0, until - Date.now()));
return () => clearTimeout(timer);
}, [until]);

// The live region stays mounted and only its text changes: a screen reader
// announces a change to a region, and often says nothing about one that
// arrives already filled. Empty, it draws nothing (terminal.css).
let text = '';
if (until !== null) {
const shortcut = formatBinding(binding);
text = shortcut
? t('terminal.resetKeptAltScreen').replace('{shortcut}', shortcut)
: t('terminal.resetKeptAltScreenNoShortcut');
}
return (
<div className="reset-notice" role="status" aria-live="polite">
{text}
</div>
);
}
2 changes: 2 additions & 0 deletions src/renderer/components/Terminal/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import CopyMode from './CopyMode';
import PinnedPrompt from './PinnedPrompt';
import PromptOutline from './PromptOutline';
import NewOutputPill from './NewOutputPill';
import ResetNotice from './ResetNotice';
import '../../styles/terminal.css';
import '../../styles/prompt-marks.css';

Expand Down Expand Up @@ -158,6 +159,7 @@ export default function TerminalPane({
/>
)}
<CopyMode active={copyModeActive} />
{visible && surfaceId && <ResetNotice surfaceId={surfaceId} />}
</div>
);
}
13 changes: 13 additions & 0 deletions src/renderer/hooks/shortcut-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,16 @@ export function isEditableTarget(target: EditableTargetLike | null | undefined):
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || !!target.isContentEditable;
}

/**
* Actions a held chord must run once, not once per auto-repeat keydown.
*
* Most bindings want the repeat (resizing a pane, stepping the font size), so
* this is opt-in. Reset terminal state is in it because a second press within
* a few seconds of one that kept the alternate screen forces it off (#357),
* and Windows starts repeating a held key well inside that window: holding
* Ctrl+Shift+R would force a live program off its screen, which is #347.
*/
export const IGNORES_KEY_REPEAT: ReadonlySet<ShortcutAction> = new Set<ShortcutAction>([
'resetTerminal',
]);
6 changes: 4 additions & 2 deletions src/renderer/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { rollupAgents } from '../store/agent-rollup';
import { focusAgentTarget } from '../store/focus-agent';
import { PaneId, SplitNode } from '../../shared/types';
import { trimTrailingWhitespace } from '../utils/copy-text';
import { GLOBAL_IN_EDITOR, isEditableTarget } from './shortcut-target';
import { GLOBAL_IN_EDITOR, IGNORES_KEY_REPEAT, isEditableTarget } from './shortcut-target';
import { matchIndexShortcut, resolveIndexTarget } from '../utils/index-shortcuts';
import { isSafeToIntercept } from '../utils/shortcut-binding';
import { followOutputFor, togglePinnedPromptFor, togglePromptOutlineFor } from '../store/prompt-actions';
Expand Down Expand Up @@ -469,8 +469,10 @@ export function useKeyboardShortcuts(
const handler = handlers[action];
if (!handler) return;

// Found a matching action — prevent default and handle it
// Found a matching action — prevent default and handle it. A repeat of
// a run-once action is still claimed, so the held chord reaches nothing.
e.preventDefault();
if (e.repeat && IGNORES_KEY_REPEAT.has(action)) return;
handler();
return;
}
Expand Down
48 changes: 37 additions & 11 deletions src/renderer/hooks/useTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,17 @@ import {
takeMouseModeReplay,
} from '../utils/mouse-modes';
import { attachVisibleRenderer, RendererHandle } from '../utils/terminal-renderer';
import { resetTerminalModes, shellOwnsScreen } from '../utils/terminal-reset';
import { resetTerminalModes } from '../utils/terminal-reset';
import { trackScreenEvents } from '../utils/screen-report';
import { forgetSurfaceShellState, previousSurfaceShellState, treeSurfaceIds, workspaceShellState } from '../utils/surface-shell-state';
import {
applyManualReset,
beginSnapshotReplay,
dispatchResetNotice,
endSnapshotReplay,
forgetAlternateScreen,
trackAlternateScreenOwner,
} from '../utils/alt-screen-owner';
import { forgetSurfaceShellState, treeSurfaceIds, workspaceShellState } from '../utils/surface-shell-state';
import { forgetProgressTurn, noteSurfaceProgress } from '../utils/progress-turn';
import { windowsPtyCompat } from '../utils/windows-pty';
import { ReplayHold } from '../utils/replay-hold';
Expand Down Expand Up @@ -1081,7 +1089,11 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true, focused = t
// set moves the cursor the same way on each.
replayHold.hold();
terminal.resize(snapshot.cols, snapshot.rows);
// The replayed ?1049h is the old screen coming back, not a program
// entering it; Reset terminal state already knows who is on it (#357).
beginSnapshotReplay(surfaceId);
terminal.write(snapsh 10BC8 ot.text, () => {
endSnapshotReplay(surfaceId);
// Parsed at last. Release, and grow to the pane's real size — applied
// to xterm and the PTY together, which is the single in-step growth
// `windowsPty` makes correct on both sides. Not only when a sync was
Expand Down Expand Up @@ -1511,6 +1523,12 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true, focused = t
// an anchored viewport go (issue #207). See registerPromptMarks.
const promptMarkDisposable = registerPromptMarks(terminal, surfaceId);

// Who is on the alternate screen, for Reset terminal state (#357). Prompt
// starts reach it through the OSC 133 handler just registered.
const altScreenDisposable = surfaceId
? trackAlternateScreenOwner(terminal, surfaceId, { notify: dispatchResetNotice })
: null;

// OSC 52: clipboard write — emitted by tmux when text is copied (set-clipboard on).
// navigator.clipboard.writeText() requires a user-gesture context which PTY data
// callbacks don't have, so we go through Electron's clipboard module via IPC.
Expand Down Expand Up @@ -1657,6 +1675,7 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true, focused = t
// default colours rather than inside the corpse of an alt screen
// (issue #175). Nothing here is written to the PTY — there is no PTY.
resetTerminalModes(terminal);
forgetAlternateScreen(id);
terminal.writeln('\r\n\x1b[2m[process exited]\x1b[0m');
clearStuckRunningState(id);
forgetProgressTurn(id);
Expand Down Expand Up @@ -1869,6 +1888,7 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true, focused = t
titleDisposable?.dispose();
screenEventsDisposable?.dispose();
promptMarkDisposable?.dispose();
altScreenDisposable?.dispose();

// Release every marker this terminal owned (issue #207).
//
Expand Down Expand Up @@ -1974,23 +1994,29 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true, focused = t
// Guarded on the PTY being present rather than on it having exited: after an
// exit the modes are already reset, and re-running it would be a no-op that
// still moved the cursor through the DECSC/DECRC sandwich.
//
// The program that entered the alternate screen may still be running and will
// never re-send ?1049h (#347), so whether to leave it is decided per surface
// in applyManualReset (#357): leave once a shell has prompted there, keep while
// a program may be drawing, force on a second press or from the palette.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.surfaceId !== surfaceId) return;
const term = xtermRef.current;
if (!term) return;
// The program that entered the alternate screen may still be running and
// will never re-send ?1049h (#347): leave it only once the shell is back
// at its prompt.
const keepAlternateScreen = !surfaceId || !shellOwnsScreen(previousSurfaceShellState(surfaceId));
resetTerminalModes(term, { keepAlternateScreen });
if (!surfaceId) {
resetTerminalModes(term, { keepAlternateScreen: true });
return;
}
applyManualReset(term, surfaceId, {
forceLeave: detail?.leaveAlternateScreen === true,
notify: dispatchResetNotice,
});
// Drop the replay cache too, or the next remount puts the modes straight
// back — same coupling as the exit path.
if (surfaceId) {
surfaceMouseModes.delete(surfaceId);
wheelLineAccum.delete(surfaceId);
}
surfaceMouseModes.delete(surfaceId);
wheelLineAccum.delete(surfaceId);
};
document.addEventListener('wmux:reset-terminal', handler);
return () => document.removeEventListener('wmux:reset-terminal', handler);
Expand Down
Loading
0