feat: add explicit Neovim panel control modes

This commit is contained in:
user
2026-07-17 07:48:07 +02:00
parent 5255839e27
commit 182f01fc5e
2 changed files with 213 additions and 42 deletions
+159 -30
View File
@@ -201,6 +201,7 @@ type NvimPanelSummary = {
detail?: string; detail?: string;
syncMode: boolean; syncMode: boolean;
syncReady: boolean; syncReady: boolean;
controlMode: boolean;
controlEnabled: boolean; controlEnabled: boolean;
}; };
@@ -258,6 +259,14 @@ function Topbar({
setScale, setScale,
nvimVisible, nvimVisible,
setNvimVisible, setNvimVisible,
nvimSyncMode,
setNvimSyncMode,
nvimControlMode,
setNvimControlMode,
nvimExpanded,
setNvimExpanded,
nvimFit,
setNvimFit,
nvimSummary, nvimSummary,
}: { }: {
toc: CardModel["toc"]; toc: CardModel["toc"];
@@ -265,6 +274,14 @@ function Topbar({
setScale: (value: number) => void; setScale: (value: number) => void;
nvimVisible: boolean; nvimVisible: boolean;
setNvimVisible: (value: boolean) => void; setNvimVisible: (value: boolean) => void;
nvimSyncMode: boolean;
setNvimSyncMode: (value: boolean) => void;
nvimControlMode: boolean;
setNvimControlMode: (value: boolean) => void;
nvimExpanded: boolean;
setNvimExpanded: (value: boolean) => void;
nvimFit: boolean;
setNvimFit: (value: boolean) => void;
nvimSummary: NvimPanelSummary; nvimSummary: NvimPanelSummary;
}) { }) {
return ( return (
@@ -286,7 +303,7 @@ function Topbar({
aria-pressed={nvimVisible} aria-pressed={nvimVisible}
onClick={() => setNvimVisible(!nvimVisible)} onClick={() => setNvimVisible(!nvimVisible)}
> >
{nvimVisible ? "F1 · Ukryj Neovim" : "F1 · Pokaż Neovim"} {nvimVisible ? "Alt+1 · Ukryj Neovim" : "Alt+1 · Pokaż Neovim"}
</button> </button>
{nvimVisible && ( {nvimVisible && (
<> <>
@@ -297,14 +314,40 @@ function Topbar({
<strong>{nvimSummary.instance ?? "Neovim MCP"}</strong> <strong>{nvimSummary.instance ?? "Neovim MCP"}</strong>
{nvimSummary.detail && <small>{nvimSummary.detail}</small>} {nvimSummary.detail && <small>{nvimSummary.detail}</small>}
</span> </span>
<span className={`nvim-control-indicator${nvimSummary.syncMode ? " is-active" : ""}${nvimSummary.syncReady ? " is-engaged" : ""}`}> <button
{nvimSummary.syncMode ? "F2 · SYNC ON" : "F2 · SYNC OFF"} type="button"
</span> className={`nvim-control-indicator${nvimSummary.syncMode ? " is-active" : ""}${nvimSummary.syncReady ? " is-engaged" : ""}`}
<span className={`nvim-work-indicator${nvimSummary.controlEnabled ? " is-active" : ""}`}> aria-pressed={nvimSyncMode}
{nvimSummary.controlEnabled ? "STEROWANIE NVIM" : "NAJEDŹ · PRACUJ"} onClick={() => setNvimSyncMode(!nvimSyncMode)}
</span> >
{nvimSyncMode ? "Alt+2 · SYNC ON" : "Alt+2 · SYNC OFF"}
</button>
<button
type="button"
className={`nvim-work-indicator${nvimControlMode ? " is-active" : ""}${nvimSummary.controlEnabled ? " is-engaged" : ""}`}
aria-pressed={nvimControlMode}
onClick={() => setNvimControlMode(!nvimControlMode)}
>
{nvimControlMode ? "Alt+3 · STEROWANIE ON" : "Alt+3 · STEROWANIE OFF"}
</button>
<button
type="button"
className={`nvim-height-toggle${nvimExpanded ? " is-active" : ""}`}
aria-pressed={nvimExpanded}
onClick={() => setNvimExpanded(!nvimExpanded)}
>
{nvimExpanded ? "Alt+4 · NORMALNA WYSOKOŚĆ" : "Alt+4 · WYSOKI PANEL"}
</button>
<button
type="button"
className={`nvim-height-toggle${nvimFit ? " is-active" : ""}`}
aria-pressed={nvimFit}
onClick={() => setNvimFit(!nvimFit)}
>
{nvimFit ? "Alt+5 · AUTO-FIT OFF" : "Alt+5 · POKAŻ CAŁY NVIM"}
</button>
<span className="nvim-shortcut-strip" aria-label="Skróty panelu Neovima"> <span className="nvim-shortcut-strip" aria-label="Skróty panelu Neovima">
<kbd>F3</kbd> pełny · <kbd>F4</kbd> odśwież · <kbd>F12</kbd> skróty <kbd>Alt+W</kbd> okna · <kbd>F12</kbd> skróty
</span> </span>
</> </>
)} )}
@@ -822,11 +865,17 @@ function keyForNvim(event: React.KeyboardEvent<HTMLElement>) {
function NeovimPanel({ function NeovimPanel({
visible, visible,
syncMode, syncMode,
controlMode,
expanded,
fit,
scale, scale,
onSummaryChange, onSummaryChange,
}: { }: {
visible: boolean; visible: boolean;
syncMode: boolean; syncMode: boolean;
controlMode: boolean;
expanded: boolean;
fit: boolean;
scale: number; scale: number;
onSummaryChange: (summary: NvimPanelSummary) => void; onSummaryChange: (summary: NvimPanelSummary) => void;
}) { }) {
@@ -853,7 +902,7 @@ function NeovimPanel({
const [checkpoint, setCheckpoint] = useState<CheckpointStatus>({ state: "idle" }); const [checkpoint, setCheckpoint] = useState<CheckpointStatus>({ state: "idle" });
const checkpointBusy = checkpoint.state === "replaying" || checkpoint.state === "verifying"; const checkpointBusy = checkpoint.state === "replaying" || checkpoint.state === "verifying";
const syncReady = syncMode && status.state === "connected" && !checkpointBusy; const syncReady = syncMode && status.state === "connected" && !checkpointBusy;
const controlEnabled = pointerInside && status.state === "connected" && !checkpointBusy; const controlEnabled = controlMode && pointerInside && status.state === "connected" && !checkpointBusy;
const redraw = () => { const redraw = () => {
if (canvasRef.current) { if (canvasRef.current) {
@@ -1037,8 +1086,9 @@ function NeovimPanel({
}, [panelHeightVh]); }, [panelHeightVh]);
useEffect(() => { useEffect(() => {
redraw(); const frame = window.requestAnimationFrame(redraw);
}, [scale]); return () => window.cancelAnimationFrame(frame);
}, [expanded, fit, scale]);
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
@@ -1070,19 +1120,20 @@ function NeovimPanel({
].filter(Boolean).join(" · "), ].filter(Boolean).join(" · "),
syncMode, syncMode,
syncReady, syncReady,
controlMode,
controlEnabled, controlEnabled,
}); });
}, [activeStep, checkpoint, controlEnabled, onSummaryChange, status, syncMode, syncReady]); }, [activeStep, checkpoint, controlEnabled, controlMode, onSummaryChange, status, syncMode, syncReady]);
useEffect(() => { useEffect(() => {
if (!controlEnabled) return; if (!controlEnabled) return;
const protectBrowserShortcuts = (event: KeyboardEvent) => { const protectBrowserShortcuts = (event: KeyboardEvent) => {
const socket = websocketRef.current; const socket = websocketRef.current;
if (socket?.readyState !== WebSocket.OPEN) return; if (socket?.readyState !== WebSocket.OPEN) return;
if (event.ctrlKey && !event.altKey && !event.metaKey && event.key.toLowerCase() === "w") { if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "w") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
if (!event.repeat) socket.send(JSON.stringify({ type: "input", keys: "<C-W>" })); if (!event.repeat) socket.send(JSON.stringify({ type: "input", keys: "<M-w>" }));
return; return;
} }
const directions: Record<string, string> = { const directions: Record<string, string> = {
@@ -1095,7 +1146,7 @@ function NeovimPanel({
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
if (!event.repeat) { if (!event.repeat) {
socket.send(JSON.stringify({ type: "input", keys: `<C-W>${directions[event.key]}` })); socket.send(JSON.stringify({ type: "input", keys: `<M-w>${directions[event.key]}` }));
} }
} }
}; };
@@ -1200,7 +1251,7 @@ function NeovimPanel({
<div <div
ref={screenRef} ref={screenRef}
className="nvim-screen-scroll" className="nvim-screen-scroll"
style={{ height: `${panelHeightVh}vh` }} style={fit ? undefined : { height: `${expanded ? 90 : panelHeightVh}vh` }}
data-sync={syncMode ? "on" : "off"} data-sync={syncMode ? "on" : "off"}
data-control={controlEnabled ? "true" : "false"} data-control={controlEnabled ? "true" : "false"}
tabIndex={0} tabIndex={0}
@@ -1224,7 +1275,7 @@ function NeovimPanel({
> >
<div <div
className="nvim-screen-stage" className="nvim-screen-stage"
style={{ height: `${panelHeightVh / Math.max(scale, 0.25)}vh` }} style={fit ? undefined : { height: `${(expanded ? 90 : panelHeightVh) / Math.max(scale, 0.25)}vh` }}
> >
<div className="nvim-screen-surface"> <div className="nvim-screen-surface">
<div className="nvim-screen-frame"> <div className="nvim-screen-frame">
@@ -2711,11 +2762,14 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
["Ctrl + Shift + ← / →", "poprzedni / następny unikalny SNAPSHOT"], ["Ctrl + Shift + ← / →", "poprzedni / następny unikalny SNAPSHOT"],
["Alt + ← / →", "poprzedni / następny BLOCK"], ["Alt + ← / →", "poprzedni / następny BLOCK"],
["Ctrl + ← / →", "poprzedni / następny TASK"], ["Ctrl + ← / →", "poprzedni / następny TASK"],
["F1", "pokaż lub ukryj panel Neovima"], ["Alt + 1", "pokaż lub ukryj panel Neovima"],
["F2", "przełącz SYNC OFF / SYNC ON"], ["Alt + 2", "przełącz SYNC OFF / SYNC ON"],
["F3", "włącz lub wyłącz pełny ekran Neovima bez suwaka"], ["Alt + 3", "uzbrój lub rozbrój sterowanie Neovimem"],
["F4", "odśwież layout i ekran Neovima"], ["Alt + 4", "wysoki panel 90% / zapisana wysokość normalna"],
["Najedź na Neovim", "przekaż klawiaturę i mysz do aktywnej sesji"], ["Alt + 5", "dopasuj cały ekran Neovima; wyłącz ręczny suwak"],
["Alt + 3, potem najedź", "przekaż klawiaturę i mysz do aktywnej sesji"],
["Alt + W", "prefiks poleceń okien Neovima (<C-W>)"],
["Alt + strzałka", "przejdź do sąsiedniego okna Neovima"],
["Zatwierdź krok", "zapisz bieżący step i timestamp do raportu"], ["Zatwierdź krok", "zapisz bieżący step i timestamp do raportu"],
["F12", "pokaż lub ukryj ten skorowidz"], ["F12", "pokaż lub ukryj ten skorowidz"],
["Esc", "zamknij skorowidz"], ["Esc", "zamknij skorowidz"],
@@ -2764,11 +2818,16 @@ function App({ model }: { model: CardModel }) {
const [nvimSyncMode, setNvimSyncModeState] = useState( const [nvimSyncMode, setNvimSyncModeState] = useState(
() => localStorage.getItem("stem-card-nvim-sync-mode") === "true", () => localStorage.getItem("stem-card-nvim-sync-mode") === "true",
); );
const [nvimFullscreen, setNvimFullscreen] = useState(false); const [nvimControlMode, setNvimControlModeState] = useState(
() => localStorage.getItem("stem-card-nvim-control-mode") === "true",
);
const [nvimExpanded, setNvimExpandedState] = useState(false);
const [nvimFit, setNvimFitState] = useState(false);
const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({ const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({
state: "hidden", state: "hidden",
syncMode: false, syncMode: false,
syncReady: false, syncReady: false,
controlMode: false,
controlEnabled: false, controlEnabled: false,
}); });
const [progress, setProgress] = useState<Progress | null>(null); const [progress, setProgress] = useState<Progress | null>(null);
@@ -2787,8 +2846,41 @@ function App({ model }: { model: CardModel }) {
}, [nvimSyncMode]); }, [nvimSyncMode]);
const setNvimVisible = (value: boolean) => { const setNvimVisible = (value: boolean) => {
setNvimVisibleState(value); setNvimVisibleState(value);
if (!value) setNvimExpandedState(false);
localStorage.setItem("stem-card-nvim-visible", String(value)); localStorage.setItem("stem-card-nvim-visible", String(value));
}; };
const setNvimControlMode = (value: boolean) => {
setNvimControlModeState(value);
localStorage.setItem("stem-card-nvim-control-mode", String(value));
};
const setNvimSyncMode = (value: boolean) => {
setNvimSyncModeState(value);
localStorage.setItem("stem-card-nvim-sync-mode", String(value));
};
const setNvimExpanded = (value: boolean) => {
if (value) setNvimVisible(true);
if (value) setNvimFitState(false);
setNvimExpandedState(value);
};
const setNvimFit = (value: boolean) => {
if (value) {
setNvimVisible(true);
setNvimExpandedState(false);
}
setNvimFitState(value);
};
useEffect(() => {
let second = 0;
const first = window.requestAnimationFrame(() => {
second = window.requestAnimationFrame(() => {
window.dispatchEvent(new Event("stem:nvim-refresh"));
});
});
return () => {
window.cancelAnimationFrame(first);
window.cancelAnimationFrame(second);
};
}, [nvimExpanded, nvimFit]);
useEffect(() => { useEffect(() => {
fetch("/api/progress") fetch("/api/progress")
.then((response) => .then((response) =>
@@ -2806,17 +2898,21 @@ function App({ model }: { model: CardModel }) {
useEffect(() => { useEffect(() => {
const navigate = (event: KeyboardEvent) => { const navigate = (event: KeyboardEvent) => {
if (event.repeat) return; if (event.repeat) return;
if (event.key === "F1") { const browserCommand = event.altKey && !event.ctrlKey && !event.metaKey
? event.code
: "";
if (browserCommand === "Digit1") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
setNvimVisibleState(current => { setNvimVisibleState(current => {
const next = !current; const next = !current;
if (!next) setNvimExpandedState(false);
localStorage.setItem("stem-card-nvim-visible", String(next)); localStorage.setItem("stem-card-nvim-visible", String(next));
return next; return next;
}); });
return; return;
} }
if (event.key === "F2") { if (browserCommand === "Digit2") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
setNvimSyncModeState(current => { setNvimSyncModeState(current => {
@@ -2826,18 +2922,40 @@ function App({ model }: { model: CardModel }) {
}); });
return; return;
} }
if (event.key === "F3") { if (browserCommand === "Digit3") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
setNvimVisibleState(true); setNvimVisibleState(true);
localStorage.setItem("stem-card-nvim-visible", "true"); localStorage.setItem("stem-card-nvim-visible", "true");
setNvimFullscreen(current => !current); setNvimControlModeState(current => {
const next = !current;
localStorage.setItem("stem-card-nvim-control-mode", String(next));
return next;
});
return; return;
} }
if (event.key === "F4") { if (browserCommand === "Digit4") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
window.dispatchEvent(new Event("stem:nvim-refresh")); setNvimVisibleState(true);
localStorage.setItem("stem-card-nvim-visible", "true");
setNvimExpandedState(current => {
const next = !current;
if (next) setNvimFitState(false);
return next;
});
return;
}
if (browserCommand === "Digit5") {
event.preventDefault();
event.stopImmediatePropagation();
setNvimVisibleState(true);
localStorage.setItem("stem-card-nvim-visible", "true");
setNvimFitState(current => {
const next = !current;
if (next) setNvimExpandedState(false);
return next;
});
return; return;
} }
if (event.key === "F12") { if (event.key === "F12") {
@@ -2925,7 +3043,7 @@ function App({ model }: { model: CardModel }) {
<> <>
<div <div
ref={viewerChromeRef} ref={viewerChromeRef}
className={`viewer-chrome${nvimFullscreen ? " is-nvim-fullscreen" : ""}`} className={`viewer-chrome${nvimExpanded ? " is-nvim-expanded" : ""}${nvimFit ? " is-nvim-fit" : ""}`}
> >
<Topbar <Topbar
toc={model.toc} toc={model.toc}
@@ -2933,11 +3051,22 @@ function App({ model }: { model: CardModel }) {
setScale={setScale} setScale={setScale}
nvimVisible={nvimVisible} nvimVisible={nvimVisible}
setNvimVisible={setNvimVisible} setNvimVisible={setNvimVisible}
nvimSyncMode={nvimSyncMode}
setNvimSyncMode={setNvimSyncMode}
nvimControlMode={nvimControlMode}
setNvimControlMode={setNvimControlMode}
nvimExpanded={nvimExpanded}
setNvimExpanded={setNvimExpanded}
nvimFit={nvimFit}
setNvimFit={setNvimFit}
nvimSummary={nvimSummary} nvimSummary={nvimSummary}
/> />
<NeovimPanel <NeovimPanel
visible={nvimVisible} visible={nvimVisible}
syncMode={nvimSyncMode} syncMode={nvimSyncMode}
controlMode={nvimControlMode}
expanded={nvimExpanded}
fit={nvimFit}
scale={scale} scale={scale}
onSummaryChange={setNvimSummary} onSummaryChange={setNvimSummary}
/> />
+54 -12
View File
@@ -8,39 +8,59 @@
position: relative; position: relative;
top: auto; top: auto;
} }
.viewer-chrome.is-nvim-fullscreen { .viewer-chrome.is-nvim-fit {
position: fixed;
z-index: 300;
inset: 0;
display: flex; display: flex;
width: 100vw; width: 100%;
height: 100vh; height: 100vh;
height: 100dvh;
max-height: 100dvh;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: #11171a; background: #11171a;
} }
.viewer-chrome.is-nvim-fullscreen .topbar { .viewer-chrome.is-nvim-fit .topbar {
flex: none; flex: none;
} }
.viewer-chrome.is-nvim-fullscreen .nvim-panel { .viewer-chrome.is-nvim-fit .nvim-panel {
display: flex; display: flex;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
} }
.viewer-chrome.is-nvim-fullscreen .nvim-screen-scroll { .viewer-chrome.is-nvim-fit .nvim-screen-scroll {
min-height: 0; min-height: 0;
height: auto !important; height: auto !important;
flex: 1; flex: 1;
} }
.viewer-chrome.is-nvim-fullscreen .nvim-resize-handle { .viewer-chrome.is-nvim-fit .nvim-resize-handle {
display: none; display: none;
} }
.viewer-chrome.is-nvim-fullscreen .nvim-screen-content { .viewer-chrome.is-nvim-fit .nvim-screen-stage {
overflow-y: hidden; width: 100%;
height: 100%;
margin: 0;
zoom: 1;
}
.viewer-chrome.is-nvim-fit .nvim-screen-content {
right: 1mm;
overflow: hidden;
scrollbar-width: none; scrollbar-width: none;
} }
.viewer-chrome.is-nvim-fullscreen .nvim-screen-content::-webkit-scrollbar { .viewer-chrome.is-nvim-fit .nvim-screen-grid {
display: flex;
width: 100%;
min-height: 0;
height: 100%;
align-items: center;
justify-content: center;
}
.viewer-chrome.is-nvim-fit .nvim-screen {
width: auto !important;
height: auto !important;
max-width: 100%;
max-height: 100%;
}
.viewer-chrome.is-nvim-fit .nvim-screen-content::-webkit-scrollbar {
display: none; display: none;
} }
.topbar-nvim-section { .topbar-nvim-section {
@@ -132,6 +152,7 @@
font: 700 10px/1.2 ui-monospace, monospace; font: 700 10px/1.2 ui-monospace, monospace;
letter-spacing: 0.04em; letter-spacing: 0.04em;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
} }
.nvim-control-indicator.is-active { .nvim-control-indicator.is-active {
border-color: #3cb3e2; border-color: #3cb3e2;
@@ -151,12 +172,33 @@
font: 700 10px/1.2 ui-monospace, monospace; font: 700 10px/1.2 ui-monospace, monospace;
letter-spacing: .03em; letter-spacing: .03em;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
} }
.nvim-work-indicator.is-active { .nvim-work-indicator.is-active {
border-color: #cf3d31; border-color: #cf3d31;
background: #fff0ee; background: #fff0ee;
color: #9f241b; color: #9f241b;
} }
.nvim-work-indicator.is-engaged {
box-shadow: inset 0 0 0 1px #cf3d31;
}
.nvim-height-toggle {
flex: none;
border: 1px solid #75848b;
border-radius: 3px;
background: #f4f6f7;
color: #425159;
padding: 3px 8px;
font: 700 10px/1.2 ui-monospace, monospace;
letter-spacing: .03em;
white-space: nowrap;
cursor: pointer;
}
.nvim-height-toggle.is-active {
border-color: #267697;
background: #eaf6fb;
color: #174d64;
}
.nvim-shortcut-strip { .nvim-shortcut-strip {
flex: none; flex: none;
color: #536168; color: #536168;