From c5518f4a7f9f6004e369bf1dccd11d2a3eca5c1a Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 01:40:50 +0100 Subject: [PATCH 01/14] feat(web): add read-only Vim mode to Monaco --- apps/web/src/shell/SPEC.md | 2 +- apps/web/src/shell/paletteCatalog.test.ts | 5 + apps/web/src/shell/paletteCatalog.ts | 9 + apps/web/src/shell/useSearchPalette.ts | 2 + e2e/editor.spec.ts | 31 ++ goal-and-requirements.md | 5 +- packages/locales/en.json | 8 + packages/resource-renderers/SPEC.md | 7 + packages/resource-renderers/src/index.ts | 1 + .../src/support/MonacoEditor.tsx | 86 ++++- .../src/support/vimMode.test.ts | 175 ++++++++++ .../resource-renderers/src/support/vimMode.ts | 326 ++++++++++++++++++ 12 files changed, 652 insertions(+), 5 deletions(-) create mode 100644 packages/resource-renderers/src/support/vimMode.test.ts create mode 100644 packages/resource-renderers/src/support/vimMode.ts diff --git a/apps/web/src/shell/SPEC.md b/apps/web/src/shell/SPEC.md index af4a991dfb..1871c53990 100644 --- a/apps/web/src/shell/SPEC.md +++ b/apps/web/src/shell/SPEC.md @@ -207,7 +207,7 @@ transition. ChatView's `/name` command is the independent keyboard entry point t - `Mod+J` does the same for the right side; - `Mod+Shift+J` toggles bottom, restoring local bottom attention, a bottom-targeted singleton, or the terminal creation surface; - `Mod+N`, and its alias `Mod+Alt+N`, open `NewWorkspaceDialog` for the context project (the active workspace's project, else the selected project). The shell owns this one keyboard-opened instance as local state — it is not store state and not the `ProjectTree`/`WelcomePanel` instances, which keep their own return-focus and prompt-seeding semantics. `Mod+N` is the canonical chord and the one shortcut chrome advertises first; the alias exists because browsers reserve Cmd/Ctrl+N for a new window and never deliver it to the page, while the desktop webview does. This is the only chord that accepts Alt. -- `Mod+K`, double Shift and the topbar search button open the search palette ([[submodule-web-search]]). `SearchPaletteHost` mounts it only while `searchOpen` is true and composes what it runs: `paletteCatalog.ts` builds the command list for the current context (a command exists only where it applies; every chord-bound action shows its shortcut and runs the very function its chord runs — the side/bottom toggles and history open live in `chordActions.ts`, which `useGlobalHotkeys` binds too) and one row per Settings section, each opening its section (the palette never flips a setting in place). `SearchPaletteHost` supplies `renderFilePreview` — a `ResourcePreview` from `@thinkrail/resource-renderers` keyed by workspace + path, with a section's heading line as its `reveal`, under `AppResourceHost`'s **`PALETTE_PREVIEW_HOST`** (built once beside the app's `HOST`): its `readFile` and `bytesUrl` carry the palette's `maxBytes` (`FILE_TOO_LARGE` becomes the "too large to preview" message), it refuses a read while the host is unreachable, and its change subscription stays silent until this connection's welcome and then refreshes once — so the palette's file and spec previews are the preview tab's renderer, bounded. The host search context names the active workspace and the context project; the host resolves the root (Project Home searches the project's Default folder). `useSearchPalette` turns store state into the props-driven palette's items: work items from `buildWorkItems` over the normalized session snapshot, so chats in workspaces this client never opened appear, named by their state record's title; **Waiting for you** is those chats that need the user (`waitingItems`); the context project and active workspace are ranking boosts. The palette's Start work row and `Mod+↵` call `PaletteStart.openDialog(text)`, which opens the keyboard-owned `NewWorkspaceDialog` instance (the `Mod+N` one) with the text as `initialPrompt` — or, when that dialog or the store's Edit & retry dialog is already open (an attention notification can open the palette over it), keeps it as it is, its draft and any creation in flight untouched, and returns `false` so the palette returns focus to it; the palette's New workspace row keeps an open dialog the same way, but does not hand focus back to it. The dialog is the only start path, so the palette never creates a workspace or sends a prompt itself. The `#` scope's Ask the agent row and `Alt+↵` call `PaletteAsk.ask(text)` instead, which starts a new chat (`startChatInTab`) in the active workspace — on Project Home in the context project's Default, entered through `enterDefaultWorkspace` under a `paletteNavigator.beginPick()` guard like Work in project folder, so a later pick or navigation while the list loads keeps the user where they went and drops the ask — with the question and the spec-citing note as its first prompt. +- `Mod+K`, double Shift and the topbar search button open the search palette ([[submodule-web-search]]). `SearchPaletteHost` mounts it only while `searchOpen` is true and composes what it runs: `paletteCatalog.ts` builds the command list for the current context (a command exists only where it applies; every chord-bound action shows its shortcut and runs the very function its chord runs — the side/bottom toggles and history open live in `chordActions.ts`, which `useGlobalHotkeys` binds too) and one row per Settings section, each opening its section (the palette never flips a setting in place). The view group also exposes `view.toggleVim`, which delegates to the focused desktop Monaco source editor through the renderer's public hook and is a no-op without one. `SearchPaletteHost` supplies `renderFilePreview` — a `ResourcePreview` from `@thinkrail/resource-renderers` keyed by workspace + path, with a section's heading line as its `reveal`, under `AppResourceHost`'s **`PALETTE_PREVIEW_HOST`** (built once beside the app's `HOST`): its `readFile` and `bytesUrl` carry the palette's `maxBytes` (`FILE_TOO_LARGE` becomes the "too large to preview" message), it refuses a read while the host is unreachable, and its change subscription stays silent until this connection's welcome and then refreshes once — so the palette's file and spec previews are the preview tab's renderer, bounded. The host search context names the active workspace and the context project; the host resolves the root (Project Home searches the project's Default folder). `useSearchPalette` turns store state into the props-driven palette's items: work items from `buildWorkItems` over the normalized session snapshot, so chats in workspaces this client never opened appear, named by their state record's title; **Waiting for you** is those chats that need the user (`waitingItems`); the context project and active workspace are ranking boosts. The palette's Start work row and `Mod+↵` call `PaletteStart.openDialog(text)`, which opens the keyboard-owned `NewWorkspaceDialog` instance (the `Mod+N` one) with the text as `initialPrompt` — or, when that dialog or the store's Edit & retry dialog is already open (an attention notification can open the palette over it), keeps it as it is, its draft and any creation in flight untouched, and returns `false` so the palette returns focus to it; the palette's New workspace row keeps an open dialog the same way, but does not hand focus back to it. The dialog is the only start path, so the palette never creates a workspace or sends a prompt itself. The `#` scope's Ask the agent row and `Alt+↵` call `PaletteAsk.ask(text)` instead, which starts a new chat (`startChatInTab`) in the active workspace — on Project Home in the context project's Default, entered through `enterDefaultWorkspace` under a `paletteNavigator.beginPick()` guard like Work in project folder, so a later pick or navigation while the list loads keeps the user where they went and drops the ask — with the question and the spec-citing note as its first prompt. - While the palette is open, `paletteBackfill.ts` makes collapsed projects' workspaces searchable: it lists, without diff stats, each open project whose workspace list this client never loaded or that lacks a workspace the snapshot reports (a peer created it while this client was away), and merges the reply in the host's order without replacing rows already held, so no worktree is diffed for it. It asks nothing while disconnected or before the welcome, keeps four requests in flight across openings, drops a reply from an earlier connection, and re-reads its queue when the connection, the welcome, the open projects or the reported workspaces change. No palette request names a chat; against an older host an unvisited chat reads as its first prompt. - A pick navigates through `activateWorkspaceFromRoute` / `selectProject`. A chat whose workspace row is missing first loads its project's list (`paletteNavigation.ts`, the same merge) and opens only if it is still the latest pick — a project pick counts — and no navigation happened meanwhile: a change of the active workspace, the selected project or a counted tab switch (the navigation tick) drops it even when it comes back (A → B → A), because the pick watches for the change (the store's `isNavigationChange`) as route restore and `chat`'s `openWorkspace` do; a tab an earlier route target opens on its own does not count. It opens only if the workspace is in that list — a stale hit or a failed list tells the user why instead of activating an id no row backs, and a chat whose deletion did not finish (`deletionPending`, held or just loaded) is refused the same way, as its sidebar row is disabled. A workspace or chat row that opened then calls `requestComposerFocus`, so the attended chat's input takes focus — for a chat row, only that chat's input. A destination showing a non-chat tab keeps its tab and focus, and the workbench drops the request then, so no chat can take it later. Host hits, project rows, the sidebar and the topbar switcher send no request: a file, spec or plan hit wants its own surface, and the sidebar keeps keyboard focus on its row. **Work in project folder** hands the same guard (`beginPick`) to `enterDefaultWorkspace` as its `stillWanted`, so a late `workspace.list` reply does not pull the user back to the Default workspace. A host hit opens through `openHostHit.ts` — a file as a tab, a spec in the Specs tool, a section there scrolled to its heading line (`requestSpecView` with the line), a transcript at its message, a plan with its chat and the Plan tool — each only after the navigator resolved `true`. - Open state lives in [[submodule-web-store]] (`searchOpen` + `openSearch` / `closeSearch`) so an aggregate attention notification from [[submodule-web-notifications]] can open the palette; the host keys it on `searchSession`, so that click resets an already-open palette to its Waiting view. It opens over the first-run wizard as the topbar Settings button does, and its project commands end the wizard the way the wizard's own project cards do. The open palette is the topmost chord layer and owns its keys (`Mod+K` and double Shift close it, Escape in its own order), so the global `Mod+K` is suppressed behind it like behind any modal; plain `Ctrl+R` is swallowed, not acted upon, while it is open (it reloads browsers on Windows/Linux). The global chord accepts neither Shift nor Alt and keeps winning over Monaco's `⌘K` chord prefix. diff --git a/apps/web/src/shell/paletteCatalog.test.ts b/apps/web/src/shell/paletteCatalog.test.ts index c675636a32..ff4d538961 100644 --- a/apps/web/src/shell/paletteCatalog.test.ts +++ b/apps/web/src/shell/paletteCatalog.test.ts @@ -47,6 +47,7 @@ function deps(overrides: Partial = {}): PaletteCatalogDeps { toggleLeft: noop, toggleRight: noop, toggleBottom: noop, + toggleVim: noop, showTool: noop, showFiles: noop, focusRegion: noop, @@ -82,6 +83,10 @@ describe("palette project and Settings commands", () => { expect(row("project.openPath", deps())?.title).toBe("Open a path on the host…"); }); + test("Vim mode is available as a command-palette action", () => { + expect(row("view.toggleVim", deps())?.title).toBe("Toggle Vim mode"); + }); + test("Settings shows the native shortcut only where the native menu owns one", () => { expect(row("settings.open", deps())?.shortcut).toBeUndefined(); expect(row("settings.open", deps({ nativeShortcuts: true }))?.shortcut).toEqual({ diff --git a/apps/web/src/shell/paletteCatalog.ts b/apps/web/src/shell/paletteCatalog.ts index a8582af220..a4983ea3d0 100644 --- a/apps/web/src/shell/paletteCatalog.ts +++ b/apps/web/src/shell/paletteCatalog.ts @@ -98,6 +98,7 @@ export interface PaletteCatalogActions { readonly toggleLeft: () => void; readonly toggleRight: () => void; readonly toggleBottom: () => void; + readonly toggleVim: () => void; readonly showTool: (workspaceId: string, tool: PaletteTool) => void; readonly showFiles: (mode: "all" | "changed") => void; readonly focusRegion: (delta: 1 | -1) => void; @@ -361,6 +362,14 @@ function viewCommands(deps: PaletteCatalogDeps): Command[] { run: () => actions.showFiles("changed"), }); } + commands.push({ + id: "view.toggleVim", + title: t("commands.view.toggleVim"), + group: "view", + icon: RiKeyboardLine, + keywords: ["vim", "vi", "normal", "insert", "visual", "editor"], + run: actions.toggleVim, + }); commands.push({ id: "view.toggleLeft", title: t("commands.view.toggleLeft"), diff --git a/apps/web/src/shell/useSearchPalette.ts b/apps/web/src/shell/useSearchPalette.ts index ced25a73f9..cd39010af4 100644 --- a/apps/web/src/shell/useSearchPalette.ts +++ b/apps/web/src/shell/useSearchPalette.ts @@ -1,4 +1,5 @@ import { t as globalT, useT } from "@thinkrail/locales/react"; +import { toggleActiveVimMode } from "@thinkrail/resource-renderers"; import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { openFindInFiles } from "../findInFiles"; @@ -274,6 +275,7 @@ export function useSearchPalette(options: SearchPaletteOptions): SearchPalettePr toggleLeft: () => toggleLeftSide(options.toggleProjectsHome), toggleRight: toggleRightSide, toggleBottom: toggleBottomPanel, + toggleVim: toggleActiveVimMode, showTool: (workspaceId, tool) => store().requestToolView(workspaceId, tool), showFiles, focusRegion: focusAdjacentRegion, diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 4f5b35892b..e1ba3fd9df 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -234,6 +234,37 @@ async function firstVisibleLine(page: Page) { }); } +test("read-only Vim mode switches modes and opens directional search", async ({ page }) => { + await openFixtureProject(page); + const workspace = await createWorkspaceViaDialog(page); + writeFileSync(join(workspace.worktreePath, "vim.ts"), longSource("vim")); + await revealWorkbenchTool(page, "files"); + await page.getByTestId("file-node").filter({ hasText: "vim.ts" }).dblclick(); + await expect(visibleEditor(page)).toContainText("vim0"); + + const toggle = page.getByTestId("vim-mode-toggle"); + await expect(toggle).toContainText("Vim: Off"); + await toggle.click(); + await expect(toggle).toContainText("Vim: Normal"); + + await visibleEditor(page).click(); + await page.keyboard.press("i"); + await expect(toggle).toContainText("Vim: Insert"); + await page.keyboard.press("Escape"); + await page.keyboard.press("v"); + await expect(toggle).toContainText("Vim: Visual"); + await page.keyboard.press("Escape"); + await expect(toggle).toContainText("Vim: Normal"); + + await page.keyboard.press("/"); + const search = page.getByTestId("vim-search-input"); + await expect(search).toBeFocused(); + await search.fill("vim10"); + await search.press("Enter"); + await expect(search).toHaveCount(0); + await expect(visibleEditor(page)).toContainText("vim10"); +}); + test("Monaco restores scroll across a tab switch", async ({ page }) => { await openFixtureProject(page); const workspace = await createWorkspaceViaDialog(page); diff --git a/goal-and-requirements.md b/goal-and-requirements.md index a92d336b3c..2733f50b84 100644 --- a/goal-and-requirements.md +++ b/goal-and-requirements.md @@ -94,7 +94,10 @@ What ThinkRail does, at product level; the linked spec owns the detail. and bottom tool panels, kept local to each window ([[submodule-web-shell-layout]]). The region that holds the keyboard stays at full contrast while the others are muted, and VS Code-style chords move between regions, into the chat input, Projects, Files and the terminal, from one keybinding table that the - palette also shows ([[submodule-web-keybindings]]). + palette also shows ([[submodule-web-keybindings]]). Desktop source files also offer an optional, + read-only Vim interaction layer with normal, insert and visual modes, directional search and repeat + counts; its visible toggle and palette command never make the file editable + ([[module-resource-renderers]], [[submodule-web-shell]]). - **Chats** — concurrent `pi` sessions per workspace, each with its own model, pi-reported token/cost, steering and follow-ups mid-run, skill and prompt-template autocomplete, and history search ([[submodule-web-chat]], [[submodule-server-agent]]). diff --git a/packages/locales/en.json b/packages/locales/en.json index 1e38fb53df..4f54f42c08 100644 --- a/packages/locales/en.json +++ b/packages/locales/en.json @@ -503,6 +503,7 @@ "commands.view.toggleBottom": "Toggle bottom panel", "commands.view.toggleLeft": "Toggle Projects panel", "commands.view.toggleRight": "Toggle right panel", + "commands.view.toggleVim": "Toggle Vim mode", "commands.view.zoomIn": "Zoom in", "commands.view.zoomOut": "Zoom out", "commands.view.zoomReset": "Reset zoom", @@ -3041,6 +3042,13 @@ "viewer.svg.diffTitle": "Vector diff of {{path}}", "viewer.svg.flexibleSize": "Flexible size", "viewer.svg.previewTitle": "Vector preview of {{path}}", + "viewer.vim.mode.insert": "Vim: Insert", + "viewer.vim.mode.normal": "Vim: Normal", + "viewer.vim.mode.visual": "Vim: Visual", + "viewer.vim.off": "Vim: Off", + "viewer.vim.search": "Vim search", + "viewer.vim.searchPlaceholder": "Search pattern", + "viewer.vim.toggle": "Toggle Vim mode", "viewer.visualDiff.commentOnNew": "Comment on new", "viewer.visualDiff.commentOnOld": "Comment on old", "viewer.visualDiff.difference": "Difference", diff --git a/packages/resource-renderers/SPEC.md b/packages/resource-renderers/SPEC.md index 0c49983d21..145be0cb64 100644 --- a/packages/resource-renderers/SPEC.md +++ b/packages/resource-renderers/SPEC.md @@ -39,6 +39,8 @@ One barrel, `@thinkrail/resource-renderers` (`src/index.ts`): `ResourcePreferences`, `ResourceScope`, `ResourceFileReviewProps`, `ResourceReviewSendProps`, `ResourceMarkdown`, `ResourceCodeHost`, `ResourceCodeLanguage`, `ResourceMarkdownProps`, `MarkdownRehypePlugins` types; +- `toggleActiveVimMode` — a narrow host hook for the currently focused desktop Monaco source editor. Vim is + renderer-local, off by default, read-only, and never reaches the mobile Pierre view or file persistence; - `canonicalWorkspacePath` — the package's copy of the app's workspace-relative path identity (`lib`'s `projectRelativePath`: POSIX separators, no `.` or empty segments, inner `..` resolved; `apps/web/src/panels/pathIdentity.test.ts` keeps the two equal); `describeResource` and `ResourcePreview` @@ -385,6 +387,11 @@ prose styles target is unchanged; the boolean attributes the sanitizer lets thro standalone checkbox's `checked`/`disabled`) are mapped to `true` because React drops an empty-string boolean. +The desktop `thinkrail/code` Monaco view optionally exposes read-only Vim interaction. Its visible +control and the host hook toggle the local mode; `normal`, `insert`, and `visual` are UI/navigation modes, +not an editing or save path. Search and count handling stay inside the Monaco view so the renderer package +owns the keyboard contract without importing the web shell. + `thinkrail/image` renders host-backed byte URLs with fit, natural-size, button/wheel zoom, intrinsic dimensions, and byte size, always on the `.media-backdrop` transparency checkerboard (the view's image and both diff frames; the difference blend keeps a flat canvas so the checker cannot leak into the diff --git a/packages/resource-renderers/src/index.ts b/packages/resource-renderers/src/index.ts index 241243c203..aef9ec6d42 100644 --- a/packages/resource-renderers/src/index.ts +++ b/packages/resource-renderers/src/index.ts @@ -71,3 +71,4 @@ export { } from "./support/rendererMount"; export { UnplacedReviewStrip } from "./support/UnplacedReviewStrip"; export { useScrollViewState, viewStateScrollTop } from "./support/useScrollViewState"; +export { toggleActiveVimMode } from "./support/vimMode"; diff --git a/packages/resource-renderers/src/support/MonacoEditor.tsx b/packages/resource-renderers/src/support/MonacoEditor.tsx index 82051a4b4f..187c068393 100644 --- a/packages/resource-renderers/src/support/MonacoEditor.tsx +++ b/packages/resource-renderers/src/support/MonacoEditor.tsx @@ -1,4 +1,6 @@ +import { RiKeyboardLine } from "@remixicon/react"; import { useT } from "@thinkrail/locales/react"; +import { Button } from "@thinkrail/ui/button"; import { onFontsChange } from "@thinkrail/ui/fonts"; import type { editor } from "monaco-editor/editor/editor.api.js"; import { @@ -32,6 +34,12 @@ import { type MonacoReviewZoneState, type ReviewCommenting, } from "./reviewWidgets"; +import { + createVimController, + initialVimState, + registerActiveVimToggle, + type VimState, +} from "./vimMode"; function focusLine(review: SurfaceReview): number | null { const range = review.focus?.anchor.selectors.find((selector) => selector.kind === "lineRange"); @@ -86,6 +94,11 @@ export default function MonacoEditor({ threads: [], composer: null, }); + const [vimState, setVimState] = useState(initialVimState); + const [searchQuery, setSearchQuery] = useState(""); + const vimControllerRef = useRef | null>(null); + const activeVimToggleRef = useRef | null>(null); + const searchInputRef = useRef(null); useLayoutEffect(() => { latestRef.current = { @@ -114,6 +127,12 @@ export default function MonacoEditor({ options: mount.options, }; const menuIcons = decorateEditorContextMenus(codeEditor); + const vimController = createVimController(codeEditor, setVimState); + const activeVimToggle = registerActiveVimToggle(vimController.toggle); + vimControllerRef.current = vimController; + activeVimToggleRef.current = activeVimToggle; + activeVimToggle.activate(); + const focusListener = codeEditor.onDidFocusEditorText(activeVimToggle.activate); if (isEditorViewState(mount.viewState)) codeEditor.restoreViewState(mount.viewState); const threads = mount.review ? attachReviewThreads(codeEditor, setReviewZones) : null; const commenting = threads @@ -124,6 +143,11 @@ export default function MonacoEditor({ return () => { const saved = codeEditor.saveViewState(); if (saved) latestRef.current.onViewState?.(saved); + focusListener.dispose(); + activeVimToggle.dispose(); + vimController.dispose(); + vimControllerRef.current = null; + activeVimToggleRef.current = null; menuIcons.dispose(); commenting?.dispose(); threads?.dispose(); @@ -160,8 +184,22 @@ export default function MonacoEditor({ }, [options]); useEffect(() => remeasureEditorFonts(font), [font]); + useEffect(() => { + if (!vimState.searchOpen) return; + setSearchQuery(""); + searchInputRef.current?.focus(); + }, [vimState.searchOpen]); const closeComposer = () => threadsRef.current?.closeComposer(); + const vimModeLabel = + vimState.mode === "normal" + ? t("viewer.vim.mode.normal") + : vimState.mode === "insert" + ? t("viewer.vim.mode.insert") + : t("viewer.vim.mode.visual"); + const submitSearch = () => vimControllerRef.current?.submitSearch(searchQuery); + const cancelSearch = () => vimControllerRef.current?.cancelSearch(); + const layoutReviewZones = () => threadsRef.current?.layout(); useLayoutEffect(() => { @@ -204,14 +242,56 @@ export default function MonacoEditor({ }, [revealLine, revealColumn, revealLength, revealTick]); return ( - <> -
+
activeVimToggleRef.current?.activate()} + > +
+ + {vimState.searchOpen ? ( +
+ + setSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitSearch(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelSearch(); + } + }} + aria-label={t("viewer.vim.search")} + data-testid="vim-search-input" + placeholder={t("viewer.vim.searchPlaceholder")} + className="h-20 w-[256px] bg-transparent text-text-default outline-none tr-code-text" + /> +
+ ) : null} - +
); } diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts new file mode 100644 index 0000000000..8753e94709 --- /dev/null +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import type { + editor, + IKeyboardEvent, + IPosition, + IRange, + ISelection, +} from "monaco-editor/editor/editor.api.js"; +import { + createVimController, + initialVimState, + registerActiveVimToggle, + toggleActiveVimMode, + type VimState, +} from "./vimMode"; + +type KeyListener = (event: IKeyboardEvent) => void; + +function position(lineNumber: number, column: number): IPosition { + return { lineNumber, column }; +} + +function makeEditor(lines: readonly string[]) { + let current = position(1, 1); + let selection: IRange | null = null; + let listener: KeyListener | null = null; + const text = lines.join("\n"); + const offsets: number[] = []; + for (const index of lines.keys()) { + const previous = offsets[index - 1] ?? 0; + const previousLine = lines[index - 1] ?? ""; + offsets.push(index === 0 ? 0 : previous + previousLine.length + 1); + } + const model = { + getValue: () => text, + getOffsetAt: (value: IPosition) => (offsets[value.lineNumber - 1] ?? 0) + value.column - 1, + getPositionAt: (offset: number) => { + const lineNumber = offsets.findLastIndex((start) => start <= offset) + 1; + return position(lineNumber, offset - (offsets[lineNumber - 1] ?? 0) + 1); + }, + getLineCount: () => lines.length, + getLineMaxColumn: (lineNumber: number) => (lines[lineNumber - 1] ?? "").length + 1, + findMatches: (query: string) => { + const matches: Array<{ range: IRange }> = []; + for (const [lineIndex, line] of lines.entries()) { + let from = 0; + while (from <= line.length) { + const found = line.indexOf(query, from); + if (found < 0) break; + matches.push({ + range: { + startLineNumber: lineIndex + 1, + startColumn: found + 1, + endLineNumber: lineIndex + 1, + endColumn: found + query.length + 1, + getStartPosition: () => position(lineIndex + 1, found + 1), + getEndPosition: () => position(lineIndex + 1, found + query.length + 1), + } as IRange, + }); + from = found + Math.max(query.length, 1); + } + } + return matches; + }, + }; + const editor = { + onKeyDown: (next: KeyListener) => { + listener = next; + return { dispose: () => (listener = null) }; + }, + getModel: () => model, + getPosition: () => current, + getSelection: () => { + const currentSelection = selection; + if (!currentSelection) return null; + return { + ...currentSelection, + getStartPosition: () => + position(currentSelection.startLineNumber, currentSelection.startColumn), + getEndPosition: () => position(currentSelection.endLineNumber, currentSelection.endColumn), + } as unknown as ISelection; + }, + setPosition: (next: IPosition) => { + current = next; + selection = null; + }, + setSelection: (next: IRange) => { + selection = next; + current = position(next.endLineNumber, next.endColumn); + }, + revealPositionInCenter: () => {}, + revealRangeInCenter: () => {}, + focus: () => {}, + }; + return { + editor: editor as unknown as editor.IStandaloneCodeEditor, + get position() { + return current; + }, + get selection() { + return selection; + }, + emit(key: string, shiftKey = false) { + const browserEvent = { + key, + shiftKey, + ctrlKey: false, + metaKey: false, + altKey: false, + isComposing: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as KeyboardEvent; + listener?.({ browserEvent } as unknown as IKeyboardEvent); + }, + }; +} + +describe("Vim mode", () => { + test("starts disabled and cycles normal, insert and visual modes", () => { + const fake = makeEditor(["one", "two"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + + expect(state).toEqual(initialVimState()); + controller.toggle(); + expect(state).toMatchObject({ enabled: true, mode: "normal" }); + fake.emit("i"); + expect(state.mode).toBe("insert"); + fake.emit("Escape"); + fake.emit("v"); + expect(state.mode).toBe("visual"); + fake.emit("Escape"); + expect(state.mode).toBe("normal"); + controller.dispose(); + }); + + test("repeats line movement with a count", () => { + const fake = makeEditor(Array.from({ length: 20 }, (_, index) => `line ${index}`)); + const controller = createVimController(fake.editor, () => {}); + controller.toggle(); + + for (const key of ["1", "0", "j"]) fake.emit(key); + expect(fake.position).toEqual(position(11, 1)); + controller.dispose(); + }); + + test("searches forward and backward with n and Shift+N", () => { + const fake = makeEditor(["alpha", "beta", "beta"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + controller.toggle(); + fake.emit("/"); + expect(state.searchOpen).toBe(true); + controller.submitSearch("beta"); + expect(fake.position).toEqual(position(2, 5)); + fake.emit("n"); + expect(fake.position).toEqual(position(3, 5)); + fake.emit("n", true); + expect(fake.position).toEqual(position(2, 5)); + controller.dispose(); + }); + + test("palette toggle reaches active editor", () => { + const fake = makeEditor(["one"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + const registration = registerActiveVimToggle(controller.toggle); + registration.activate(); + toggleActiveVimMode(); + expect(state.enabled).toBe(true); + registration.dispose(); + controller.dispose(); + }); +}); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts new file mode 100644 index 0000000000..c9415f4631 --- /dev/null +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -0,0 +1,326 @@ +import type { editor, IKeyboardEvent, IPosition } from "monaco-editor/editor/editor.api.js"; + +export type VimMode = "normal" | "insert" | "visual"; +export type VimSearchDirection = "forward" | "backward"; + +export interface VimState { + enabled: boolean; + mode: VimMode; + searchOpen: boolean; + searchDirection: VimSearchDirection; +} + +const INITIAL_STATE: VimState = { + enabled: false, + mode: "normal", + searchOpen: false, + searchDirection: "forward", +}; + +let activeOwner: symbol | null = null; +let activeToggle: (() => void) | null = null; + +export function registerActiveVimToggle(toggle: () => void): { + activate: () => void; + dispose: () => void; +} { + const owner = Symbol("vim-editor"); + return { + activate: () => { + activeOwner = owner; + activeToggle = toggle; + }, + dispose: () => { + if (activeOwner !== owner) return; + activeOwner = null; + activeToggle = null; + }, + }; +} + +export function toggleActiveVimMode(): void { + activeToggle?.(); +} + +function positionKey(position: IPosition): number { + return position.lineNumber * 1_000_000 + position.column; +} + +function isWordCharacter(value: string | undefined): boolean { + return value !== undefined && /[A-Za-z0-9_]/u.test(value); +} + +function moveWordForward(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = model.getOffsetAt(position); + while (offset < text.length && isWordCharacter(text[offset])) offset += 1; + while (offset < text.length && !isWordCharacter(text[offset])) offset += 1; + return model.getPositionAt(offset); +} + +function moveWordBackward(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = Math.max(0, model.getOffsetAt(position) - 1); + while (offset > 0 && !isWordCharacter(text[offset])) offset -= 1; + while (offset > 0 && isWordCharacter(text[offset - 1])) offset -= 1; + return model.getPositionAt(offset); +} + +function movePosition( + model: editor.ITextModel, + position: IPosition, + key: string, + count: number, +): IPosition | null { + let next = position; + for (let index = 0; index < count; index += 1) { + if (key === "h") { + next = { lineNumber: next.lineNumber, column: Math.max(1, next.column - 1) }; + } else if (key === "l") { + next = { + lineNumber: next.lineNumber, + column: Math.min(model.getLineMaxColumn(next.lineNumber), next.column + 1), + }; + } else if (key === "j") { + const lineNumber = Math.min(model.getLineCount(), next.lineNumber + 1); + next = { + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), next.column), + }; + } else if (key === "k") { + const lineNumber = Math.max(1, next.lineNumber - 1); + next = { + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), next.column), + }; + } else if (key === "w") { + next = moveWordForward(model, next); + } else if (key === "b") { + next = moveWordBackward(model, next); + } else { + return null; + } + } + return next; +} + +function clampCount(value: string): number { + const count = Number.parseInt(value, 10); + return Number.isSafeInteger(count) && count > 0 ? Math.min(count, 9999) : 1; +} + +function consume(event: IKeyboardEvent): void { + event.browserEvent.preventDefault(); + event.browserEvent.stopPropagation(); +} + +export function createVimController( + codeEditor: editor.IStandaloneCodeEditor, + onStateChange: (state: VimState) => void, +): { + toggle: () => void; + submitSearch: (query: string) => void; + cancelSearch: () => void; + dispose: () => void; +} { + let state = INITIAL_STATE; + let count = ""; + let anchor: IPosition | null = null; + let lastSearch: { query: string; direction: VimSearchDirection } | null = null; + + const update = (patch: Partial) => { + state = { ...state, ...patch }; + onStateChange(state); + }; + + const setPosition = (position: IPosition) => { + codeEditor.setPosition(position); + codeEditor.revealPositionInCenter(position); + }; + + const setVisualPosition = (position: IPosition) => { + if (!anchor) anchor = codeEditor.getPosition() ?? position; + codeEditor.setSelection({ + selectionStartLineNumber: anchor.lineNumber, + selectionStartColumn: anchor.column, + positionLineNumber: position.lineNumber, + positionColumn: position.column, + }); + codeEditor.revealPositionInCenter(position); + }; + + const performSearch = (query: string, direction: VimSearchDirection, repetitions = 1) => { + const model = codeEditor.getModel(); + const current = codeEditor.getPosition(); + if (!model || !current || query.length === 0) return; + const selection = codeEditor.getSelection(); + let searchPosition = + direction === "forward" + ? (selection?.getEndPosition() ?? current) + : (selection?.getStartPosition() ?? current); + const matches = model.findMatches(query, false, false, false, null, false); + if (matches.length === 0) return; + for (let index = 0; index < repetitions; index += 1) { + const currentOffset = positionKey(searchPosition); + const match = + direction === "forward" + ? (matches.find( + (candidate) => positionKey(candidate.range.getStartPosition()) > currentOffset, + ) ?? matches[0]) + : (matches.findLast( + (candidate) => positionKey(candidate.range.getStartPosition()) < currentOffset, + ) ?? matches[matches.length - 1]); + if (!match) return; + searchPosition = + direction === "forward" ? match.range.getEndPosition() : match.range.getStartPosition(); + codeEditor.setSelection(match.range); + codeEditor.revealRangeInCenter(match.range); + } + lastSearch = { query, direction }; + }; + + const submitSearch = (query: string) => { + update({ searchOpen: false }); + performSearch(query, state.searchDirection); + codeEditor.focus(); + }; + + const cancelSearch = () => { + update({ searchOpen: false }); + codeEditor.focus(); + }; + + const toggle = () => { + const enabled = !state.enabled; + count = ""; + anchor = null; + update({ enabled, mode: "normal", searchOpen: false }); + codeEditor.focus(); + }; + + const onKeyDown = (event: IKeyboardEvent) => { + if (!state.enabled || event.browserEvent.isComposing) return; + const key = event.browserEvent.key; + if (event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey) { + if (key !== "Escape") return; + } + + if (state.mode === "insert") { + if (key !== "Escape") return; + consume(event); + update({ mode: "normal" }); + return; + } + + if (key === "Escape") { + consume(event); + count = ""; + anchor = null; + update({ mode: "normal" }); + return; + } + + if (key >= "1" && key <= "9") { + consume(event); + count += key; + return; + } + if (key === "0" && count.length > 0) { + consume(event); + count += key; + return; + } + + if (key === "/" || key === "?") { + consume(event); + count = ""; + update({ searchOpen: true, searchDirection: key === "/" ? "forward" : "backward" }); + return; + } + + if (key === "i" || key === "a") { + consume(event); + count = ""; + update({ mode: "insert" }); + return; + } + + if (key === "v") { + consume(event); + count = ""; + anchor = codeEditor.getPosition(); + update({ mode: "visual" }); + return; + } + + if (key === "n" && lastSearch) { + consume(event); + const repetitions = clampCount(count); + count = ""; + performSearch( + lastSearch.query, + event.browserEvent.shiftKey + ? lastSearch.direction === "forward" + ? "backward" + : "forward" + : lastSearch.direction, + repetitions, + ); + return; + } + + if (key === "G") { + consume(event); + const model = codeEditor.getModel(); + if (model) { + const lineNumber = + count.length > 0 + ? Math.min(model.getLineCount(), clampCount(count)) + : model.getLineCount(); + const position = { lineNumber, column: 1 }; + if (state.mode === "visual") setVisualPosition(position); + else setPosition(position); + } + count = ""; + return; + } + + if (key === "0" || key === "$") { + consume(event); + const position = codeEditor.getPosition(); + const model = codeEditor.getModel(); + if (position && model) { + const next = { + lineNumber: position.lineNumber, + column: key === "0" ? 1 : model.getLineMaxColumn(position.lineNumber), + }; + if (state.mode === "visual") setVisualPosition(next); + else setPosition(next); + } + count = ""; + return; + } + + const model = codeEditor.getModel(); + const position = codeEditor.getPosition(); + if (!model || !position) return; + const next = movePosition(model, position, key, clampCount(count)); + if (!next) return; + consume(event); + count = ""; + if (state.mode === "visual") setVisualPosition(next); + else setPosition(next); + }; + + const listener = codeEditor.onKeyDown(onKeyDown); + return { + toggle, + submitSearch, + cancelSearch, + dispose: () => listener.dispose(), + }; +} + +export function initialVimState(): VimState { + return { ...INITIAL_STATE }; +} From bc12d590762473c3518067ad8962ab6d601518c0 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 01:47:13 +0100 Subject: [PATCH 02/14] fix(web): handle shifted Vim search navigation --- packages/resource-renderers/src/support/vimMode.test.ts | 2 +- packages/resource-renderers/src/support/vimMode.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 8753e94709..02548831b4 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -156,7 +156,7 @@ describe("Vim mode", () => { expect(fake.position).toEqual(position(2, 5)); fake.emit("n"); expect(fake.position).toEqual(position(3, 5)); - fake.emit("n", true); + fake.emit("N", true); expect(fake.position).toEqual(position(2, 5)); controller.dispose(); }); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index c9415f4631..fe1d9d6595 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -201,6 +201,7 @@ export function createVimController( const onKeyDown = (event: IKeyboardEvent) => { if (!state.enabled || event.browserEvent.isComposing) return; const key = event.browserEvent.key; + const lowerKey = key.toLowerCase(); if (event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey) { if (key !== "Escape") return; } @@ -253,7 +254,7 @@ export function createVimController( return; } - if (key === "n" && lastSearch) { + if (lowerKey === "n" && lastSearch) { consume(event); const repetitions = clampCount(count); count = ""; From 874eb1d9958e48f43830cea1bb5f1f4fd3aac540 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 13:52:54 +0100 Subject: [PATCH 03/14] feat(web): expand read-only Vim navigation --- CHANGELOG.md | 11 + README.md | 2 + packages/resource-renderers/SPEC.md | 7 +- .../src/support/vimMode.test.ts | 68 +++ .../resource-renderers/src/support/vimMode.ts | 451 ++++++++++++++++-- 5 files changed, 503 insertions(+), 36 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..742e749874 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Unreleased + +### Added + +- Expanded optional read-only Vim mode in desktop Monaco source editors toward `vim.tiny` navigation parity. + - Toggle with the visible `Vim` control or the command palette's **Toggle Vim mode** command. + - Supports `Normal`, `Insert`, and `Visual` modes, including character, line, and block selection. + - Supports core motions, marks, jump history, viewport commands, `/` and `?` searches, `n`, `Shift+N`, `*`, `#`, and repeat counts such as `10j`. + - Mutation commands, registers, clipboard, Ex commands, undo, and file saves remain unavailable because the editor is read-only. diff --git a/README.md b/README.md index 1b69285703..baeccf5b12 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ For latest version, see the [Releases page](https://github.com/JetBrains/thinkra - **An IDE around the agent.** ThinkRail comes with Monaco editor tabs, a file tree integrated with git to show code changes as they happen, terminals, and document support. Rendered Markdown previews have a heading outline that follows the section you're reading; hide it or move it to either side. The pane you're typing in stays at full contrast while the rest is dimmed, and VS Code-style keys move you around: `F6` steps between panes, `⌘L` jumps to the chat input, `⌘0` to Projects, `⌘1`–`⌘9` to an editor group, and `` ⌃` `` to the terminal. +- **Read-only Vim mode.** In a desktop Monaco source editor, turn Vim on with the `Vim` control or the command palette's **Toggle Vim mode** command. `Normal` supports `h`/`j`/`k`/`l`, `w`/`b`/`e`, `0`/`^`/`$`, `gg`/`G`, `f`/`F`/`t`/`T`, `%`, `H`/`M`/`L`, `zt`/`zz`/`zb`, marks, jump history, and repeat counts such as `10j`. `Visual` supports character (`v`), line (`V`), block (`Ctrl+V`), and `o`/`O` endpoint changes; `Insert` remains a read-only mode. Press `/` to search forward or `?` backward, `n` for the next match, `Shift+N` for the previous one, and `*`/`#` for the word under the cursor. Mutation commands, registers, clipboard, Ex commands, undo, and file saves remain unavailable. + - **Powerful chat features.** You can open several concurrent work sessions per workspace, each with its own model. Send a prompt, or queue it as a follow-up, delegate to subagents, and work together on a shared plan: the agent updates it, and you review and edit. - **Find anything from one box.** `⌘K` (or a double tap of Shift) opens a palette that finds chats and the messages inside them, workspaces and projects, files in the current workspace, specs, and plans, with a preview pane. It also runs the app's commands and opens Settings sections, and with nothing typed it lists the chats waiting for you. Type a task and press `⌘↵` to open Create workspace with it as the prompt. Press `⌘⇧F` to find text inside the workspace's files as you type: matches are grouped by file, with match-case and whole-word toggles and a code preview, and `↵` opens the file at that line. diff --git a/packages/resource-renderers/SPEC.md b/packages/resource-renderers/SPEC.md index 145be0cb64..63ae714b1e 100644 --- a/packages/resource-renderers/SPEC.md +++ b/packages/resource-renderers/SPEC.md @@ -389,8 +389,11 @@ boolean. The desktop `thinkrail/code` Monaco view optionally exposes read-only Vim interaction. Its visible control and the host hook toggle the local mode; `normal`, `insert`, and `visual` are UI/navigation modes, -not an editing or save path. Search and count handling stay inside the Monaco view so the renderer package -owns the keyboard contract without importing the web shell. +not an editing or save path. The supported read-only subset follows the high-value core exposed by compact +Linux Vim builds: word/line/character/bracket motions, `gg`/`G`, viewport motions, marks and jump history, +character/line/block Visual selection, search and repeat counts. Mutation operators, registers, clipboard, +Ex commands, undo/redo and macros remain outside the contract. Search and count handling stay inside the +Monaco view so the renderer package owns the keyboard contract without importing the web shell. `thinkrail/image` renders host-backed byte URLs with fit, natural-size, button/wheel zoom, intrinsic dimensions, and byte size, always on the `.media-backdrop` transparency checkerboard (the view's image diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 02548831b4..806cb3a2d2 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -39,7 +39,20 @@ function makeEditor(lines: readonly string[]) { return position(lineNumber, offset - (offsets[lineNumber - 1] ?? 0) + 1); }, getLineCount: () => lines.length, + getLineContent: (lineNumber: number) => lines[lineNumber - 1] ?? "", getLineMaxColumn: (lineNumber: number) => (lines[lineNumber - 1] ?? "").length + 1, + getWordAtPosition: (value: IPosition) => { + const line = lines[value.lineNumber - 1] ?? ""; + const index = value.column - 1; + const match = /[A-Za-z0-9_]+/gu; + for (const item of line.matchAll(match)) { + const start = item.index ?? 0; + if (index >= start && index <= start + item[0].length) { + return { word: item[0], startColumn: start + 1, endColumn: start + item[0].length + 1 }; + } + } + return null; + }, findMatches: (query: string) => { const matches: Array<{ range: IRange }> = []; for (const [lineIndex, line] of lines.entries()) { @@ -88,8 +101,24 @@ function makeEditor(lines: readonly string[]) { selection = next; current = position(next.endLineNumber, next.endColumn); }, + setSelections: (next: readonly ISelection[]) => { + const last = next[next.length - 1]; + if (!last) return; + selection = last as unknown as IRange; + current = position(last.positionLineNumber, last.positionColumn); + }, + getVisibleRanges: () => [ + { + startLineNumber: 1, + startColumn: 1, + endLineNumber: Math.min(lines.length, 5), + endColumn: 1, + } as IRange, + ], revealPositionInCenter: () => {}, revealRangeInCenter: () => {}, + revealLineNearTop: () => {}, + revealLineInCenter: () => {}, focus: () => {}, }; return { @@ -161,6 +190,45 @@ describe("Vim mode", () => { controller.dispose(); }); + test("supports Vim tiny navigation, Visual variants, marks and viewport jumps", () => { + const fake = makeEditor([" alpha beta", "(gamma)", "delta"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + controller.toggle(); + + fake.emit("^"); + expect(fake.position).toEqual(position(1, 3)); + fake.emit("e"); + expect(fake.position).toEqual(position(1, 7)); + fake.emit("f"); + fake.emit("b"); + expect(fake.position).toEqual(position(1, 9)); + fake.emit("G"); + expect(fake.position.lineNumber).toBe(3); + fake.emit("g"); + fake.emit("g"); + expect(fake.position.lineNumber).toBe(1); + + fake.emit("m"); + fake.emit("a"); + fake.emit("G"); + fake.emit("`"); + fake.emit("a"); + expect(fake.position.lineNumber).toBe(1); + + fake.emit("V"); + expect(state.visualMode).toBe("line"); + fake.emit("Escape"); + fake.emit("v"); + expect(state.visualMode).toBe("character"); + fake.emit("Escape"); + fake.emit("v"); + fake.emit("V", true); + expect(state.visualMode).toBe("line"); + fake.emit("Escape"); + controller.dispose(); + }); + test("palette toggle reaches active editor", () => { const fake = makeEditor(["one"]); let state: VimState = initialVimState(); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index fe1d9d6595..52adb1c2d8 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -1,11 +1,18 @@ -import type { editor, IKeyboardEvent, IPosition } from "monaco-editor/editor/editor.api.js"; +import type { + editor, + IKeyboardEvent, + IPosition, + ISelection, +} from "monaco-editor/editor/editor.api.js"; export type VimMode = "normal" | "insert" | "visual"; +export type VimVisualMode = "character" | "line" | "block"; export type VimSearchDirection = "forward" | "backward"; export interface VimState { enabled: boolean; mode: VimMode; + visualMode: VimVisualMode | null; searchOpen: boolean; searchDirection: VimSearchDirection; } @@ -13,10 +20,20 @@ export interface VimState { const INITIAL_STATE: VimState = { enabled: false, mode: "normal", + visualMode: null, searchOpen: false, searchDirection: "forward", }; +const BRACKET_PAIRS: Readonly> = { + "(": ")", + ")": "(", + "[": "]", + "]": "[", + "{": "}", + "}": "{", +}; + let activeOwner: symbol | null = null; let activeToggle: (() => void) | null = null; @@ -66,6 +83,14 @@ function moveWordBackward(model: editor.ITextModel, position: IPosition): IPosit return model.getPositionAt(offset); } +function moveWordEnd(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = model.getOffsetAt(position); + while (offset < text.length && !isWordCharacter(text[offset])) offset += 1; + while (offset < text.length && isWordCharacter(text[offset + 1])) offset += 1; + return model.getPositionAt(offset); +} + function movePosition( model: editor.ITextModel, position: IPosition, @@ -97,6 +122,8 @@ function movePosition( next = moveWordForward(model, next); } else if (key === "b") { next = moveWordBackward(model, next); + } else if (key === "e") { + next = moveWordEnd(model, next); } else { return null; } @@ -114,6 +141,91 @@ function consume(event: IKeyboardEvent): void { event.browserEvent.stopPropagation(); } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function lineStart( + model: editor.ITextModel, + lineNumber: number, + firstNonBlank: boolean, +): IPosition { + if (!firstNonBlank) return { lineNumber, column: 1 }; + const match = model.getLineContent(lineNumber).search(/\S/u); + return { lineNumber, column: match < 0 ? 1 : match + 1 }; +} + +function findCharacter( + model: editor.ITextModel, + position: IPosition, + character: string, + direction: "forward" | "backward", + before: boolean, + count: number, +): IPosition { + let current = position; + for (let repetition = 0; repetition < count; repetition += 1) { + const line = model.getLineContent(current.lineNumber); + const start = direction === "forward" ? current.column : current.column - 2; + const found = + direction === "forward" + ? line.indexOf(character, Math.max(0, start)) + : line.lastIndexOf(character, Math.max(0, start)); + if (found < 0) return position; + const column = found + 1 + (before ? (direction === "forward" ? -1 : 1) : 0); + current = { lineNumber: current.lineNumber, column: Math.max(1, column) }; + } + return current; +} + +function matchingBracket(model: editor.ITextModel, position: IPosition): IPosition | null { + const text = model.getValue(); + const offset = model.getOffsetAt(position); + let index = offset; + let character = text[index]; + if (!BRACKET_PAIRS[character ?? ""]) { + index = offset + 1; + while (index < text.length && !BRACKET_PAIRS[text[index] ?? ""]) index += 1; + character = text[index]; + } + const pair = BRACKET_PAIRS[character ?? ""]; + if (!pair) return null; + const forward = character === "(" || character === "[" || character === "{"; + let depth = 0; + for ( + let cursor = index; + forward ? cursor < text.length : cursor >= 0; + cursor += forward ? 1 : -1 + ) { + const current = text[cursor]; + if (current === character) depth += 1; + if (current === pair) { + depth -= 1; + if (depth === 0) return model.getPositionAt(cursor); + } + } + return null; +} + +function visibleLine( + codeEditor: editor.IStandaloneCodeEditor, + kind: "top" | "middle" | "bottom", + count: number, +): number | null { + const range = codeEditor.getVisibleRanges()[0]; + if (!range) return null; + if (kind === "top") return Math.min(range.startLineNumber + count - 1, range.endLineNumber); + if (kind === "bottom") return Math.max(range.endLineNumber - count + 1, range.startLineNumber); + return Math.floor((range.startLineNumber + range.endLineNumber) / 2); +} + +type PendingCommand = + | { kind: "character"; direction: "forward" | "backward"; before: boolean; count: number } + | { kind: "g"; count: number } + | { kind: "z"; count: number } + | { kind: "mark-set" } + | { kind: "mark-jump"; exact: boolean }; + export function createVimController( codeEditor: editor.IStandaloneCodeEditor, onStateChange: (state: VimState) => void, @@ -126,20 +238,77 @@ export function createVimController( let state = INITIAL_STATE; let count = ""; let anchor: IPosition | null = null; - let lastSearch: { query: string; direction: VimSearchDirection } | null = null; + let pending: PendingCommand | null = null; + let lastSearch: { query: string; direction: VimSearchDirection; wholeWord: boolean } | null = + null; + const marks = new Map(); + let jumpHistory: IPosition[] = []; + let jumpIndex = -1; const update = (patch: Partial) => { state = { ...state, ...patch }; onStateChange(state); }; - const setPosition = (position: IPosition) => { + const currentPosition = () => codeEditor.getPosition(); + + const recordJump = (target: IPosition) => { + const current = currentPosition(); + if (!current) return; + if ( + jumpIndex >= 0 && + jumpHistory[jumpIndex]?.lineNumber === current.lineNumber && + jumpHistory[jumpIndex]?.column === current.column + ) { + jumpHistory = jumpHistory.slice(0, jumpIndex + 1); + } else { + jumpHistory = jumpHistory.slice(0, jumpIndex + 1); + jumpHistory.push(current); + } + jumpHistory.push(target); + jumpIndex = jumpHistory.length - 1; + }; + + const setPosition = (position: IPosition, jump = false) => { + if (jump) recordJump(position); codeEditor.setPosition(position); codeEditor.revealPositionInCenter(position); }; const setVisualPosition = (position: IPosition) => { - if (!anchor) anchor = codeEditor.getPosition() ?? position; + if (!anchor) anchor = currentPosition() ?? position; + if (state.visualMode === "line") { + const model = codeEditor.getModel(); + const end = Math.max(anchor.lineNumber, position.lineNumber); + const anchorEnd = model?.getLineMaxColumn(anchor.lineNumber) ?? anchor.column; + const positionEnd = model?.getLineMaxColumn(position.lineNumber) ?? position.column; + codeEditor.setSelection({ + selectionStartLineNumber: anchor.lineNumber, + selectionStartColumn: anchor.lineNumber <= position.lineNumber ? 1 : anchorEnd, + positionLineNumber: position.lineNumber, + positionColumn: position.lineNumber >= anchor.lineNumber ? positionEnd : 1, + }); + codeEditor.revealLineInCenter(end); + return; + } + if (state.visualMode === "block") { + const startLine = Math.min(anchor.lineNumber, position.lineNumber); + const endLine = Math.max(anchor.lineNumber, position.lineNumber); + const startColumn = Math.min(anchor.column, position.column); + const endColumn = Math.max(anchor.column, position.column); + const selections: ISelection[] = []; + for (let line = startLine; line <= endLine; line += 1) { + selections.push({ + selectionStartLineNumber: line, + selectionStartColumn: startColumn, + positionLineNumber: line, + positionColumn: endColumn, + }); + } + codeEditor.setSelections(selections); + codeEditor.revealPositionInCenter(position); + return; + } codeEditor.setSelection({ selectionStartLineNumber: anchor.lineNumber, selectionStartColumn: anchor.column, @@ -149,17 +318,29 @@ export function createVimController( codeEditor.revealPositionInCenter(position); }; - const performSearch = (query: string, direction: VimSearchDirection, repetitions = 1) => { + const applyPosition = (position: IPosition, jump = false) => { + if (state.mode === "visual") setVisualPosition(position); + else setPosition(position, jump); + }; + + const performSearch = ( + query: string, + direction: VimSearchDirection, + repetitions = 1, + wholeWord = false, + ) => { const model = codeEditor.getModel(); - const current = codeEditor.getPosition(); + const current = currentPosition(); if (!model || !current || query.length === 0) return; const selection = codeEditor.getSelection(); let searchPosition = direction === "forward" ? (selection?.getEndPosition() ?? current) : (selection?.getStartPosition() ?? current); - const matches = model.findMatches(query, false, false, false, null, false); + const searchQuery = wholeWord ? `\\b${escapeRegex(query)}\\b` : query; + const matches = model.findMatches(searchQuery, false, wholeWord, false, null, false); if (matches.length === 0) return; + recordJump(matches[0]?.range.getStartPosition() ?? current); for (let index = 0; index < repetitions; index += 1) { const currentOffset = positionKey(searchPosition); const match = @@ -176,7 +357,16 @@ export function createVimController( codeEditor.setSelection(match.range); codeEditor.revealRangeInCenter(match.range); } - lastSearch = { query, direction }; + lastSearch = { query, direction, wholeWord }; + }; + + const jumpHistoryBy = (delta: -1 | 1) => { + if (jumpHistory.length === 0) return; + const nextIndex = Math.max(0, Math.min(jumpHistory.length - 1, jumpIndex + delta)); + if (nextIndex === jumpIndex) return; + jumpIndex = nextIndex; + const position = jumpHistory[jumpIndex]; + if (position) setPosition(position); }; const submitSearch = (query: string) => { @@ -194,7 +384,8 @@ export function createVimController( const enabled = !state.enabled; count = ""; anchor = null; - update({ enabled, mode: "normal", searchOpen: false }); + pending = null; + update({ enabled, mode: "normal", visualMode: null, searchOpen: false }); codeEditor.focus(); }; @@ -202,14 +393,13 @@ export function createVimController( if (!state.enabled || event.browserEvent.isComposing) return; const key = event.browserEvent.key; const lowerKey = key.toLowerCase(); - if (event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey) { - if (key !== "Escape") return; - } + const hasModifier = + event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey; if (state.mode === "insert") { if (key !== "Escape") return; consume(event); - update({ mode: "normal" }); + update({ mode: "normal", visualMode: null }); return; } @@ -217,7 +407,111 @@ export function createVimController( consume(event); count = ""; anchor = null; - update({ mode: "normal" }); + pending = null; + update({ mode: "normal", visualMode: null }); + return; + } + + if ( + hasModifier && + event.browserEvent.ctrlKey && + (lowerKey === "v" || + lowerKey === "o" || + lowerKey === "i" || + lowerKey === "d" || + lowerKey === "u" || + lowerKey === "f" || + lowerKey === "b") + ) { + if (lowerKey === "v") { + consume(event); + anchor = currentPosition(); + update({ mode: "visual", visualMode: "block" }); + return; + } + if (lowerKey === "o" || lowerKey === "i") { + consume(event); + jumpHistoryBy(lowerKey === "o" ? -1 : 1); + return; + } + const visible = codeEditor.getVisibleRanges()[0]; + const current = currentPosition(); + if (visible && current) { + const page = Math.max(1, visible.endLineNumber - visible.startLineNumber); + const delta = + lowerKey === "f" + ? page + : lowerKey === "b" + ? -page + : lowerKey === "d" + ? Math.floor(page / 2) + : lowerKey === "u" + ? -Math.floor(page / 2) + : 0; + if (delta !== 0) { + consume(event); + const model = codeEditor.getModel(); + if (model) { + const lineNumber = Math.max( + 1, + Math.min(model.getLineCount(), current.lineNumber + delta), + ); + setPosition({ + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), current.column), + }); + } + return; + } + } + } + + if (pending) { + const command = pending; + pending = null; + if (command.kind === "character" && key.length === 1) { + const model = codeEditor.getModel(); + const position = currentPosition(); + if (model && position) + applyPosition( + findCharacter(model, position, key, command.direction, command.before, command.count), + ); + consume(event); + return; + } + if (command.kind === "g" && lowerKey === "g") { + const model = codeEditor.getModel(); + if (model) applyPosition(lineStart(model, command.count, false)); + consume(event); + return; + } + if (command.kind === "z" && (lowerKey === "t" || lowerKey === "z" || lowerKey === "b")) { + const line = currentPosition()?.lineNumber ?? null; + if (line !== null) { + if (lowerKey === "t") codeEditor.revealLineNearTop(line); + else codeEditor.revealLineInCenter(line); + } + consume(event); + return; + } + if (command.kind === "mark-set" && /^[a-z]$/u.test(key)) { + const position = currentPosition(); + if (position) marks.set(key, position); + consume(event); + return; + } + if (command.kind === "mark-jump" && /^[a-z]$/u.test(key)) { + const mark = marks.get(key); + if (mark) { + const model = codeEditor.getModel(); + setPosition( + command.exact ? mark : model ? lineStart(model, mark.lineNumber, false) : mark, + true, + ); + } + consume(event); + return; + } return; } @@ -239,18 +533,29 @@ export function createVimController( return; } - if (key === "i" || key === "a") { + if (key === "i" || key === "a" || key === "A") { consume(event); count = ""; - update({ mode: "insert" }); + update({ mode: "insert", visualMode: null }); return; } - if (key === "v") { + if (key === "v" || key === "V") { consume(event); count = ""; - anchor = codeEditor.getPosition(); - update({ mode: "visual" }); + anchor = currentPosition(); + update({ mode: "visual", visualMode: key === "V" ? "line" : "character" }); + return; + } + + if (state.mode === "visual" && (key === "o" || key === "O")) { + const position = currentPosition(); + if (position && anchor) { + const previousAnchor = anchor; + anchor = position; + setVisualPosition(previousAnchor); + } + consume(event); return; } @@ -266,10 +571,62 @@ export function createVimController( : "forward" : lastSearch.direction, repetitions, + lastSearch.wholeWord, ); return; } + if (key === "*" || key === "#") { + const model = codeEditor.getModel(); + const position = currentPosition(); + const word = model && position ? model.getWordAtPosition(position)?.word : null; + if (word) { + consume(event); + performSearch(word, key === "*" ? "forward" : "backward", clampCount(count), true); + } + count = ""; + return; + } + + if (key === "f" || key === "F" || key === "t" || key === "T") { + consume(event); + pending = { + kind: "character", + direction: key === "f" || key === "t" ? "forward" : "backward", + before: key === "t" || key === "T", + count: clampCount(count), + }; + count = ""; + return; + } + + if (key === "g") { + consume(event); + pending = { kind: "g", count: clampCount(count) }; + count = ""; + return; + } + + if (key === "z") { + consume(event); + pending = { kind: "z", count: clampCount(count) }; + count = ""; + return; + } + + if (key === "m") { + consume(event); + pending = { kind: "mark-set" }; + count = ""; + return; + } + if (key === "'" || key === "`") { + consume(event); + pending = { kind: "mark-jump", exact: key === "`" }; + count = ""; + return; + } + if (key === "G") { consume(event); const model = codeEditor.getModel(); @@ -278,39 +635,65 @@ export function createVimController( count.length > 0 ? Math.min(model.getLineCount(), clampCount(count)) : model.getLineCount(); - const position = { lineNumber, column: 1 }; - if (state.mode === "visual") setVisualPosition(position); - else setPosition(position); + applyPosition(lineStart(model, lineNumber, false), true); } count = ""; return; } - if (key === "0" || key === "$") { + if (key === "^" || key === "0" || key === "$") { consume(event); - const position = codeEditor.getPosition(); + const position = currentPosition(); const model = codeEditor.getModel(); if (position && model) { - const next = { - lineNumber: position.lineNumber, - column: key === "0" ? 1 : model.getLineMaxColumn(position.lineNumber), - }; - if (state.mode === "visual") setVisualPosition(next); - else setPosition(next); + const next = + key === "$" + ? { + lineNumber: position.lineNumber, + column: model.getLineMaxColumn(position.lineNumber), + } + : lineStart(model, position.lineNumber, key === "^"); + applyPosition(next); + } + count = ""; + return; + } + + if (key === "%") { + const model = codeEditor.getModel(); + const position = currentPosition(); + const match = model && position ? matchingBracket(model, position) : null; + if (match) { + consume(event); + applyPosition(match, true); + } + count = ""; + return; + } + + if (key === "H" || key === "M" || key === "L") { + const line = visibleLine( + codeEditor, + key === "H" ? "top" : key === "L" ? "bottom" : "middle", + clampCount(count), + ); + const model = codeEditor.getModel(); + if (line !== null && model) { + consume(event); + applyPosition(lineStart(model, line, false)); } count = ""; return; } const model = codeEditor.getModel(); - const position = codeEditor.getPosition(); + const position = currentPosition(); if (!model || !position) return; - const next = movePosition(model, position, key, clampCount(count)); + const next = movePosition(model, position, lowerKey, clampCount(count)); if (!next) return; consume(event); count = ""; - if (state.mode === "visual") setVisualPosition(next); - else setPosition(next); + applyPosition(next); }; const listener = codeEditor.onKeyDown(onKeyDown); From f18a58cb7f199556f7ffa91dcb9d5b21ae92ba43 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 14:26:30 +0100 Subject: [PATCH 04/14] fix(web): address Vim review feedback --- .../src/support/MonacoEditor.tsx | 2 +- .../src/support/vimMode.test.ts | 31 +++++++++++++++++-- .../resource-renderers/src/support/vimMode.ts | 29 +++++++++++------ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/resource-renderers/src/support/MonacoEditor.tsx b/packages/resource-renderers/src/support/MonacoEditor.tsx index 187c068393..54b455ae76 100644 --- a/packages/resource-renderers/src/support/MonacoEditor.tsx +++ b/packages/resource-renderers/src/support/MonacoEditor.tsx @@ -282,7 +282,7 @@ export default function MonacoEditor({ aria-label={t("viewer.vim.search")} data-testid="vim-search-input" placeholder={t("viewer.vim.searchPlaceholder")} - className="h-20 w-[256px] bg-transparent text-text-default outline-none tr-code-text" + className="h-20 w-[256px] bg-transparent text-text-default outline-none focus-visible:focus-ring tr-code-text" />
) : null} diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 806cb3a2d2..2db1550688 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -23,6 +23,7 @@ function position(lineNumber: number, column: number): IPosition { function makeEditor(lines: readonly string[]) { let current = position(1, 1); let selection: IRange | null = null; + let scrollTop = 0; let listener: KeyListener | null = null; const text = lines.join("\n"); const offsets: number[] = []; @@ -115,6 +116,11 @@ function makeEditor(lines: readonly string[]) { endColumn: 1, } as IRange, ], + getTopForLineNumber: (lineNumber: number) => (lineNumber - 1) * 20, + getLayoutInfo: () => ({ height: 100 }), + setScrollTop: (next: number) => { + scrollTop = next; + }, revealPositionInCenter: () => {}, revealRangeInCenter: () => {}, revealLineNearTop: () => {}, @@ -129,11 +135,14 @@ function makeEditor(lines: readonly string[]) { get selection() { return selection; }, - emit(key: string, shiftKey = false) { + get scrollTop() { + return scrollTop; + }, + emit(key: string, shiftKey = false, ctrlKey = false) { const browserEvent = { key, shiftKey, - ctrlKey: false, + ctrlKey, metaKey: false, altKey: false, isComposing: false, @@ -203,8 +212,24 @@ describe("Vim mode", () => { fake.emit("f"); fake.emit("b"); expect(fake.position).toEqual(position(1, 9)); + const counted = makeEditor(["a b c d"]); + const countedController = createVimController(counted.editor, () => {}); + countedController.toggle(); + counted.emit("2"); + counted.emit("t"); + counted.emit(" "); + expect(counted.position).toEqual(position(1, 3)); + countedController.dispose(); fake.emit("G"); expect(fake.position.lineNumber).toBe(3); + const viewport = makeEditor(Array.from({ length: 20 }, (_, index) => `line ${index}`)); + const viewportController = createVimController(viewport.editor, () => {}); + viewportController.toggle(); + viewport.emit("G"); + viewport.emit("z"); + viewport.emit("b"); + expect(viewport.scrollTop).toBeGreaterThan(0); + viewportController.dispose(); fake.emit("g"); fake.emit("g"); expect(fake.position.lineNumber).toBe(1); @@ -218,6 +243,8 @@ describe("Vim mode", () => { fake.emit("V"); expect(state.visualMode).toBe("line"); + fake.emit("a", false, true); + expect(state.mode).toBe("visual"); fake.emit("Escape"); fake.emit("v"); expect(state.visualMode).toBe("character"); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index 52adb1c2d8..47f501057e 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -163,19 +163,19 @@ function findCharacter( before: boolean, count: number, ): IPosition { - let current = position; + const line = model.getLineContent(position.lineNumber); + let cursor = direction === "forward" ? position.column : position.column - 2; + let found = -1; for (let repetition = 0; repetition < count; repetition += 1) { - const line = model.getLineContent(current.lineNumber); - const start = direction === "forward" ? current.column : current.column - 2; - const found = + found = direction === "forward" - ? line.indexOf(character, Math.max(0, start)) - : line.lastIndexOf(character, Math.max(0, start)); + ? line.indexOf(character, Math.max(0, cursor)) + : line.lastIndexOf(character, Math.max(0, cursor)); if (found < 0) return position; - const column = found + 1 + (before ? (direction === "forward" ? -1 : 1) : 0); - current = { lineNumber: current.lineNumber, column: Math.max(1, column) }; + cursor = direction === "forward" ? found + 1 : found - 1; } - return current; + const column = found + 1 + (before ? (direction === "forward" ? -1 : 1) : 0); + return { lineNumber: position.lineNumber, column: Math.max(1, column) }; } function matchingBracket(model: editor.ITextModel, position: IPosition): IPosition | null { @@ -207,6 +207,14 @@ function matchingBracket(model: editor.ITextModel, position: IPosition): IPositi return null; } +function revealLineNearBottom(codeEditor: editor.IStandaloneCodeEditor, lineNumber: number): void { + const lineTop = codeEditor.getTopForLineNumber(lineNumber); + const nextLineTop = codeEditor.getTopForLineNumber(lineNumber + 1); + const lineHeight = Math.max(1, nextLineTop - lineTop); + const viewportHeight = codeEditor.getLayoutInfo().height; + codeEditor.setScrollTop(Math.max(0, lineTop - viewportHeight + lineHeight)); +} + function visibleLine( codeEditor: editor.IStandaloneCodeEditor, kind: "top" | "middle" | "bottom", @@ -466,6 +474,8 @@ export function createVimController( } } + if (hasModifier) return; + if (pending) { const command = pending; pending = null; @@ -489,6 +499,7 @@ export function createVimController( const line = currentPosition()?.lineNumber ?? null; if (line !== null) { if (lowerKey === "t") codeEditor.revealLineNearTop(line); + else if (lowerKey === "b") revealLineNearBottom(codeEditor, line); else codeEditor.revealLineInCenter(line); } consume(event); From b325909cdd79fba4e89e398bb694c08dcf9855d9 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 14:32:27 +0100 Subject: [PATCH 05/14] fix(web): address remaining Vim review findings --- .../src/support/MonacoEditor.tsx | 3 +- .../src/support/vimMode.test.ts | 27 ++++++ .../resource-renderers/src/support/vimMode.ts | 88 +++++++++++++------ 3 files changed, 90 insertions(+), 28 deletions(-) diff --git a/packages/resource-renderers/src/support/MonacoEditor.tsx b/packages/resource-renderers/src/support/MonacoEditor.tsx index 54b455ae76..7fc5652e37 100644 --- a/packages/resource-renderers/src/support/MonacoEditor.tsx +++ b/packages/resource-renderers/src/support/MonacoEditor.tsx @@ -2,6 +2,7 @@ import { RiKeyboardLine } from "@remixicon/react"; import { useT } from "@thinkrail/locales/react"; import { Button } from "@thinkrail/ui/button"; import { onFontsChange } from "@thinkrail/ui/fonts"; +import { isImeComposing } from "@thinkrail/ui/keyboard"; import type { editor } from "monaco-editor/editor/editor.api.js"; import { useEffect, @@ -131,7 +132,6 @@ export default function MonacoEditor({ const activeVimToggle = registerActiveVimToggle(vimController.toggle); vimControllerRef.current = vimController; activeVimToggleRef.current = activeVimToggle; - activeVimToggle.activate(); const focusListener = codeEditor.onDidFocusEditorText(activeVimToggle.activate); if (isEditorViewState(mount.viewState)) codeEditor.restoreViewState(mount.viewState); const threads = mount.review ? attachReviewThreads(codeEditor, setReviewZones) : null; @@ -271,6 +271,7 @@ export default function MonacoEditor({ value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} onKeyDown={(event) => { + if (isImeComposing(event.nativeEvent)) return; if (event.key === "Enter") { event.preventDefault(); submitSearch(); diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 2db1550688..2bb3bd54d1 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -25,6 +25,7 @@ function makeEditor(lines: readonly string[]) { let selection: IRange | null = null; let scrollTop = 0; let listener: KeyListener | null = null; + let contentListener: (() => void) | null = null; const text = lines.join("\n"); const offsets: number[] = []; for (const index of lines.keys()) { @@ -76,12 +77,32 @@ function makeEditor(lines: readonly string[]) { } return matches; }, + findNextMatch: (query: string, start: IPosition) => + model + .findMatches(query) + .find( + (candidate) => + candidate.range.startLineNumber * 1_000_000 + candidate.range.startColumn > + start.lineNumber * 1_000_000 + start.column, + ), + findPreviousMatch: (query: string, start: IPosition) => + model + .findMatches(query) + .findLast( + (candidate) => + candidate.range.startLineNumber * 1_000_000 + candidate.range.startColumn < + start.lineNumber * 1_000_000 + start.column, + ), }; const editor = { onKeyDown: (next: KeyListener) => { listener = next; return { dispose: () => (listener = null) }; }, + onDidChangeModelContent: (next: () => void) => { + contentListener = next; + return { dispose: () => (contentListener = null) }; + }, getModel: () => model, getPosition: () => current, getSelection: () => { @@ -151,6 +172,9 @@ function makeEditor(lines: readonly string[]) { } as unknown as KeyboardEvent; listener?.({ browserEvent } as unknown as IKeyboardEvent); }, + modelChanged() { + contentListener?.(); + }, }; } @@ -252,6 +276,9 @@ describe("Vim mode", () => { fake.emit("v"); fake.emit("V", true); expect(state.visualMode).toBe("line"); + fake.modelChanged(); + expect(state.visualMode).toBeNull(); + expect(state.mode).toBe("normal"); fake.emit("Escape"); controller.dispose(); }); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index 47f501057e..624ce67642 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -59,10 +59,6 @@ export function toggleActiveVimMode(): void { activeToggle?.(); } -function positionKey(position: IPosition): number { - return position.lineNumber * 1_000_000 + position.column; -} - function isWordCharacter(value: string | undefined): boolean { return value !== undefined && /[A-Za-z0-9_]/u.test(value); } @@ -86,9 +82,14 @@ function moveWordBackward(model: editor.ITextModel, position: IPosition): IPosit function moveWordEnd(model: editor.ITextModel, position: IPosition): IPosition { const text = model.getValue(); let offset = model.getOffsetAt(position); + if (isWordCharacter(text[offset]) && isWordCharacter(text[offset + 1])) { + while (offset < text.length && isWordCharacter(text[offset + 1])) offset += 1; + return model.getPositionAt(offset); + } + offset += 1; while (offset < text.length && !isWordCharacter(text[offset])) offset += 1; while (offset < text.length && isWordCharacter(text[offset + 1])) offset += 1; - return model.getPositionAt(offset); + return model.getPositionAt(Math.min(offset, text.length)); } function movePosition( @@ -246,6 +247,7 @@ export function createVimController( let state = INITIAL_STATE; let count = ""; let anchor: IPosition | null = null; + let visualPosition: IPosition | null = null; let pending: PendingCommand | null = null; let lastSearch: { query: string; direction: VimSearchDirection; wholeWord: boolean } | null = null; @@ -259,6 +261,8 @@ export function createVimController( }; const currentPosition = () => codeEditor.getPosition(); + const motionPosition = () => + state.mode === "visual" ? (visualPosition ?? currentPosition()) : currentPosition(); const recordJump = (target: IPosition) => { const current = currentPosition(); @@ -285,6 +289,7 @@ export function createVimController( const setVisualPosition = (position: IPosition) => { if (!anchor) anchor = currentPosition() ?? position; + visualPosition = position; if (state.visualMode === "line") { const model = codeEditor.getModel(); const end = Math.max(anchor.lineNumber, position.lineNumber); @@ -336,36 +341,42 @@ export function createVimController( direction: VimSearchDirection, repetitions = 1, wholeWord = false, + saveSearch = true, ) => { const model = codeEditor.getModel(); const current = currentPosition(); if (!model || !current || query.length === 0) return; const selection = codeEditor.getSelection(); - let searchPosition = + let searchPosition: IPosition = direction === "forward" ? (selection?.getEndPosition() ?? current) : (selection?.getStartPosition() ?? current); const searchQuery = wholeWord ? `\\b${escapeRegex(query)}\\b` : query; - const matches = model.findMatches(searchQuery, false, wholeWord, false, null, false); - if (matches.length === 0) return; - recordJump(matches[0]?.range.getStartPosition() ?? current); + let match: ReturnType | null = null; for (let index = 0; index < repetitions; index += 1) { - const currentOffset = positionKey(searchPosition); - const match = + match = direction === "forward" - ? (matches.find( - (candidate) => positionKey(candidate.range.getStartPosition()) > currentOffset, - ) ?? matches[0]) - : (matches.findLast( - (candidate) => positionKey(candidate.range.getStartPosition()) < currentOffset, - ) ?? matches[matches.length - 1]); + ? model.findNextMatch(searchQuery, searchPosition, wholeWord, false, null, false) + : model.findPreviousMatch(searchQuery, searchPosition, wholeWord, false, null, false); + if (!match) { + searchPosition = + direction === "forward" + ? { lineNumber: 1, column: 1 } + : model.getPositionAt(model.getValueLength()); + match = + direction === "forward" + ? model.findNextMatch(searchQuery, searchPosition, wholeWord, false, null, false) + : model.findPreviousMatch(searchQuery, searchPosition, wholeWord, false, null, false); + } if (!match) return; searchPosition = direction === "forward" ? match.range.getEndPosition() : match.range.getStartPosition(); - codeEditor.setSelection(match.range); - codeEditor.revealRangeInCenter(match.range); } - lastSearch = { query, direction, wholeWord }; + if (!match) return; + recordJump(match.range.getStartPosition()); + codeEditor.setSelection(match.range); + codeEditor.revealRangeInCenter(match.range); + if (saveSearch) lastSearch = { query, direction, wholeWord }; }; const jumpHistoryBy = (delta: -1 | 1) => { @@ -392,6 +403,7 @@ export function createVimController( const enabled = !state.enabled; count = ""; anchor = null; + visualPosition = null; pending = null; update({ enabled, mode: "normal", visualMode: null, searchOpen: false }); codeEditor.focus(); @@ -407,6 +419,7 @@ export function createVimController( if (state.mode === "insert") { if (key !== "Escape") return; consume(event); + visualPosition = null; update({ mode: "normal", visualMode: null }); return; } @@ -415,6 +428,7 @@ export function createVimController( consume(event); count = ""; anchor = null; + visualPosition = null; pending = null; update({ mode: "normal", visualMode: null }); return; @@ -434,7 +448,9 @@ export function createVimController( if (lowerKey === "v") { consume(event); anchor = currentPosition(); + visualPosition = anchor; update({ mode: "visual", visualMode: "block" }); + if (visualPosition) setVisualPosition(visualPosition); return; } if (lowerKey === "o" || lowerKey === "i") { @@ -443,7 +459,7 @@ export function createVimController( return; } const visible = codeEditor.getVisibleRanges()[0]; - const current = currentPosition(); + const current = motionPosition(); if (visible && current) { const page = Math.max(1, visible.endLineNumber - visible.startLineNumber); const delta = @@ -481,7 +497,7 @@ export function createVimController( pending = null; if (command.kind === "character" && key.length === 1) { const model = codeEditor.getModel(); - const position = currentPosition(); + const position = motionPosition(); if (model && position) applyPosition( findCharacter(model, position, key, command.direction, command.before, command.count), @@ -491,7 +507,7 @@ export function createVimController( } if (command.kind === "g" && lowerKey === "g") { const model = codeEditor.getModel(); - if (model) applyPosition(lineStart(model, command.count, false)); + if (model) applyPosition(lineStart(model, command.count, false), true); consume(event); return; } @@ -555,15 +571,18 @@ export function createVimController( consume(event); count = ""; anchor = currentPosition(); + visualPosition = anchor; update({ mode: "visual", visualMode: key === "V" ? "line" : "character" }); + if (visualPosition) setVisualPosition(visualPosition); return; } if (state.mode === "visual" && (key === "o" || key === "O")) { - const position = currentPosition(); + const position = motionPosition(); if (position && anchor) { const previousAnchor = anchor; anchor = position; + visualPosition = previousAnchor; setVisualPosition(previousAnchor); } consume(event); @@ -583,6 +602,7 @@ export function createVimController( : lastSearch.direction, repetitions, lastSearch.wholeWord, + false, ); return; } @@ -654,7 +674,7 @@ export function createVimController( if (key === "^" || key === "0" || key === "$") { consume(event); - const position = currentPosition(); + const position = motionPosition(); const model = codeEditor.getModel(); if (position && model) { const next = @@ -698,7 +718,7 @@ export function createVimController( } const model = codeEditor.getModel(); - const position = currentPosition(); + const position = motionPosition(); if (!model || !position) return; const next = movePosition(model, position, lowerKey, clampCount(count)); if (!next) return; @@ -708,11 +728,25 @@ export function createVimController( }; const listener = codeEditor.onKeyDown(onKeyDown); + const modelContentListener = codeEditor.onDidChangeModelContent(() => { + count = ""; + anchor = null; + visualPosition = null; + pending = null; + lastSearch = null; + marks.clear(); + jumpHistory = []; + jumpIndex = -1; + update({ mode: "normal", visualMode: null, searchOpen: false }); + }); return { toggle, submitSearch, cancelSearch, - dispose: () => listener.dispose(), + dispose: () => { + listener.dispose(); + modelContentListener.dispose(); + }, }; } From fcb3a2068eb8ce5889c62e387561367ea773d602 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 14:39:23 +0100 Subject: [PATCH 06/14] fix(web): preserve Vim selection semantics --- .../src/support/vimMode.test.ts | 1 + .../resource-renderers/src/support/vimMode.ts | 25 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 2bb3bd54d1..cdc1012668 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -272,6 +272,7 @@ describe("Vim mode", () => { fake.emit("Escape"); fake.emit("v"); expect(state.visualMode).toBe("character"); + expect(fake.selection).not.toBeNull(); fake.emit("Escape"); fake.emit("v"); fake.emit("V", true); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index 624ce67642..fa0a4e9672 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -59,6 +59,10 @@ export function toggleActiveVimMode(): void { activeToggle?.(); } +function positionKey(position: IPosition): number { + return position.lineNumber * 1_000_000 + position.column; +} + function isWordCharacter(value: string | undefined): boolean { return value !== undefined && /[A-Za-z0-9_]/u.test(value); } @@ -305,28 +309,39 @@ export function createVimController( return; } if (state.visualMode === "block") { + const model = codeEditor.getModel(); const startLine = Math.min(anchor.lineNumber, position.lineNumber); const endLine = Math.max(anchor.lineNumber, position.lineNumber); const startColumn = Math.min(anchor.column, position.column); const endColumn = Math.max(anchor.column, position.column); + const endColumnExclusive = model + ? Math.min(model.getLineMaxColumn(endLine), endColumn + 1) + : endColumn + 1; const selections: ISelection[] = []; for (let line = startLine; line <= endLine; line += 1) { selections.push({ selectionStartLineNumber: line, selectionStartColumn: startColumn, positionLineNumber: line, - positionColumn: endColumn, + positionColumn: endColumnExclusive, }); } codeEditor.setSelections(selections); codeEditor.revealPositionInCenter(position); return; } + const model = codeEditor.getModel(); + const anchorIsBefore = positionKey(anchor) <= positionKey(position); + const start = anchorIsBefore ? anchor : position; + const end = anchorIsBefore ? position : anchor; + const endColumnExclusive = model + ? Math.min(model.getLineMaxColumn(end.lineNumber), end.column + 1) + : end.column + 1; codeEditor.setSelection({ - selectionStartLineNumber: anchor.lineNumber, - selectionStartColumn: anchor.column, - positionLineNumber: position.lineNumber, - positionColumn: position.column, + selectionStartLineNumber: start.lineNumber, + selectionStartColumn: start.column, + positionLineNumber: end.lineNumber, + positionColumn: endColumnExclusive, }); codeEditor.revealPositionInCenter(position); }; From dc540055af2866588f86b8c867cb7a38899ca5f7 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 14:41:11 +0100 Subject: [PATCH 07/14] fix(web): preserve Visual Vim motions --- .../resource-renderers/src/support/vimMode.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index fa0a4e9672..5422885a26 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -400,7 +400,7 @@ export function createVimController( if (nextIndex === jumpIndex) return; jumpIndex = nextIndex; const position = jumpHistory[jumpIndex]; - if (position) setPosition(position); + if (position) applyPosition(position); }; const submitSearch = (query: string) => { @@ -462,6 +462,7 @@ export function createVimController( ) { if (lowerKey === "v") { consume(event); + count = ""; anchor = currentPosition(); visualPosition = anchor; update({ mode: "visual", visualMode: "block" }); @@ -470,15 +471,18 @@ export function createVimController( } if (lowerKey === "o" || lowerKey === "i") { consume(event); + count = ""; jumpHistoryBy(lowerKey === "o" ? -1 : 1); return; } + const ctrlCount = clampCount(count); + count = ""; const visible = codeEditor.getVisibleRanges()[0]; const current = motionPosition(); if (visible && current) { const page = Math.max(1, visible.endLineNumber - visible.startLineNumber); const delta = - lowerKey === "f" + (lowerKey === "f" ? page : lowerKey === "b" ? -page @@ -486,23 +490,24 @@ export function createVimController( ? Math.floor(page / 2) : lowerKey === "u" ? -Math.floor(page / 2) - : 0; + : 0) * ctrlCount; + consume(event); if (delta !== 0) { - consume(event); const model = codeEditor.getModel(); if (model) { const lineNumber = Math.max( 1, Math.min(model.getLineCount(), current.lineNumber + delta), ); - setPosition({ + applyPosition({ lineNumber, column: Math.min(model.getLineMaxColumn(lineNumber), current.column), }); } - return; } + return; } + consume(event); } if (hasModifier) return; @@ -546,7 +551,7 @@ export function createVimController( const mark = marks.get(key); if (mark) { const model = codeEditor.getModel(); - setPosition( + applyPosition( command.exact ? mark : model ? lineStart(model, mark.lineNumber, false) : mark, true, ); From db79e54a606743e9724d8649db839333740d9f8e Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 01:40:50 +0100 Subject: [PATCH 08/14] feat(web): add read-only Vim mode to Monaco --- apps/web/src/shell/SPEC.md | 4 +- apps/web/src/shell/paletteCatalog.test.ts | 5 + apps/web/src/shell/paletteCatalog.ts | 9 + apps/web/src/shell/useSearchPalette.ts | 2 + e2e/editor.spec.ts | 31 ++ goal-and-requirements.md | 5 +- packages/locales/en.json | 8 + packages/resource-renderers/SPEC.md | 7 + packages/resource-renderers/src/index.ts | 1 + .../src/support/MonacoEditor.tsx | 86 ++++- .../src/support/vimMode.test.ts | 175 ++++++++++ .../resource-renderers/src/support/vimMode.ts | 326 ++++++++++++++++++ 12 files changed, 653 insertions(+), 6 deletions(-) create mode 100644 packages/resource-renderers/src/support/vimMode.test.ts create mode 100644 packages/resource-renderers/src/support/vimMode.ts diff --git a/apps/web/src/shell/SPEC.md b/apps/web/src/shell/SPEC.md index edd96f2357..1fc85a1941 100644 --- a/apps/web/src/shell/SPEC.md +++ b/apps/web/src/shell/SPEC.md @@ -206,8 +206,8 @@ transition. ChatView's `/name` command is the independent keyboard entry point t - `Mod+B` toggles the left side, restoring local group/tab attention or an eligible singleton tool; - `Mod+J` does the same for the right side; - `Mod+Shift+J` toggles bottom, restoring local bottom attention, a bottom-targeted singleton, or the terminal creation surface; -- `Mod+N`, and its alias `Mod+Alt+N`, open `NewWorkspaceDialog` for the context project (the active workspace's project, else the selected project). The shell owns this one keyboard-opened instance as local state — it is not store state and not the `ProjectTree`/`WelcomePanel` instances, which keep their own return-focus and prompt-seeding semantics. `Mod+N` is the canonical chord and the one shortcut chrome advertises first; the alias exists because browsers reserve Cmd/Ctrl+N for a new window and never deliver it to the page, while the desktop webview does. -- `Mod+K`, double Shift and the topbar search button open the search palette ([[submodule-web-search]]). `SearchPaletteHost` mounts it only while `searchOpen` is true and composes what it runs: `paletteCatalog.ts` builds the command list for the current context (a command exists only where it applies; every chord-bound action shows its shortcut and runs the very function its chord runs — the side/bottom toggles and history open live in `chordActions.ts`, which `useGlobalHotkeys` binds too) and one row per Settings section, each opening its section (the palette never flips a setting in place). `SearchPaletteHost` supplies `renderFilePreview` — a `ResourcePreview` from `@thinkrail/resource-renderers` keyed by workspace + path, with a section's heading line as its `reveal`, under `AppResourceHost`'s **`PALETTE_PREVIEW_HOST`** (built once beside the app's `HOST`): its `readFile` and `bytesUrl` carry the palette's `maxBytes` (`FILE_TOO_LARGE` becomes the "too large to preview" message), it refuses a read while the host is unreachable, and its change subscription stays silent until this connection's welcome and then refreshes once — so the palette's file and spec previews are the preview tab's renderer, bounded. The host search context names the active workspace and the context project; the host resolves the root (Project Home searches the project's Default folder). `useSearchPalette` turns store state into the props-driven palette's items: work items from `buildWorkItems` over the normalized session snapshot, so chats in workspaces this client never opened appear, named by their state record's title; **Waiting for you** is those chats that need the user (`waitingItems`); the context project and active workspace are ranking boosts. The palette's Start work row and `Mod+↵` call `PaletteStart.openDialog(text)`, which opens the keyboard-owned `NewWorkspaceDialog` instance (the `Mod+N` one) with the text as `initialPrompt` — or, when that dialog or the store's Edit & retry dialog is already open (an attention notification can open the palette over it), keeps it as it is, its draft and any creation in flight untouched, and returns `false` so the palette returns focus to it; the palette's New workspace row keeps an open dialog the same way, but does not hand focus back to it. The dialog is the only start path, so the palette never creates a workspace or sends a prompt itself. The `#` scope's Ask the agent row and `Alt+↵` call `PaletteAsk.ask(text)` instead, which starts a new chat (`startChatInTab`) in the active workspace — on Project Home in the context project's Default, entered through `enterDefaultWorkspace` under a `paletteNavigator.beginPick()` guard like Work in project folder, so a later pick or navigation while the list loads keeps the user where they went and drops the ask — with the question and the spec-citing note as its first prompt. +- `Mod+N`, and its alias `Mod+Alt+N`, open `NewWorkspaceDialog` for the context project (the active workspace's project, else the selected project). The shell owns this one keyboard-opened instance as local state — it is not store state and not the `ProjectTree`/`WelcomePanel` instances, which keep their own return-focus and prompt-seeding semantics. `Mod+N` is the canonical chord and the one shortcut chrome advertises first; the alias exists because browsers reserve Cmd/Ctrl+N for a new window and never deliver it to the page, while the desktop webview does. This is the only chord that accepts Alt. +- `Mod+K`, double Shift and the topbar search button open the search palette ([[submodule-web-search]]). `SearchPaletteHost` mounts it only while `searchOpen` is true and composes what it runs: `paletteCatalog.ts` builds the command list for the current context (a command exists only where it applies; every chord-bound action shows its shortcut and runs the very function its chord runs — the side/bottom toggles and history open live in `chordActions.ts`, which `useGlobalHotkeys` binds too) and one row per Settings section, each opening its section (the palette never flips a setting in place). The view group also exposes `view.toggleVim`, which delegates to the focused desktop Monaco source editor through the renderer's public hook and is a no-op without one. `SearchPaletteHost` supplies `renderFilePreview` — a `ResourcePreview` from `@thinkrail/resource-renderers` keyed by workspace + path, with a section's heading line as its `reveal`, under `AppResourceHost`'s **`PALETTE_PREVIEW_HOST`** (built once beside the app's `HOST`): its `readFile` and `bytesUrl` carry the palette's `maxBytes` (`FILE_TOO_LARGE` becomes the "too large to preview" message), it refuses a read while the host is unreachable, and its change subscription stays silent until this connection's welcome and then refreshes once — so the palette's file and spec previews are the preview tab's renderer, bounded. The host search context names the active workspace and the context project; the host resolves the root (Project Home searches the project's Default folder). `useSearchPalette` turns store state into the props-driven palette's items: work items from `buildWorkItems` over the normalized session snapshot, so chats in workspaces this client never opened appear, named by their state record's title; **Waiting for you** is those chats that need the user (`waitingItems`); the context project and active workspace are ranking boosts. The palette's Start work row and `Mod+↵` call `PaletteStart.openDialog(text)`, which opens the keyboard-owned `NewWorkspaceDialog` instance (the `Mod+N` one) with the text as `initialPrompt` — or, when that dialog or the store's Edit & retry dialog is already open (an attention notification can open the palette over it), keeps it as it is, its draft and any creation in flight untouched, and returns `false` so the palette returns focus to it; the palette's New workspace row keeps an open dialog the same way, but does not hand focus back to it. The dialog is the only start path, so the palette never creates a workspace or sends a prompt itself. The `#` scope's Ask the agent row and `Alt+↵` call `PaletteAsk.ask(text)` instead, which starts a new chat (`startChatInTab`) in the active workspace — on Project Home in the context project's Default, entered through `enterDefaultWorkspace` under a `paletteNavigator.beginPick()` guard like Work in project folder, so a later pick or navigation while the list loads keeps the user where they went and drops the ask — with the question and the spec-citing note as its first prompt. - While the palette is open, `paletteBackfill.ts` makes collapsed projects' workspaces searchable: it lists, without diff stats, each open project whose workspace list this client never loaded or that lacks a workspace the snapshot reports (a peer created it while this client was away), and merges the reply in the host's order without replacing rows already held, so no worktree is diffed for it. It asks nothing while disconnected or before the welcome, keeps four requests in flight across openings, drops a reply from an earlier connection, and re-reads its queue when the connection, the welcome, the open projects or the reported workspaces change. No palette request names a chat; against an older host an unvisited chat reads as its first prompt. - A pick navigates through `activateWorkspaceFromRoute` / `selectProject`. A chat whose workspace row is missing first loads its project's list (`paletteNavigation.ts`, the same merge) and opens only if it is still the latest pick — a project pick counts — and no navigation happened meanwhile: a change of the active workspace, the selected project or a counted tab switch (the navigation tick) drops it even when it comes back (A → B → A), because the pick watches for the change (the store's `isNavigationChange`) as route restore and `chat`'s `openWorkspace` do; a tab an earlier route target opens on its own does not count. It opens only if the workspace is in that list — a stale hit or a failed list tells the user why instead of activating an id no row backs, and a chat whose deletion did not finish (`deletionPending`, held or just loaded) is refused the same way, as its sidebar row is disabled. A workspace or chat row that opened then calls `requestComposerFocus`, so the attended chat's input takes focus — for a chat row, only that chat's input. A destination showing a non-chat tab keeps its tab and focus, and the workbench drops the request then, so no chat can take it later. Host hits, project rows, the sidebar and the topbar switcher send no request: a file, spec or plan hit wants its own surface, and the sidebar keeps keyboard focus on its row. **Work in project folder** hands the same guard (`beginPick`) to `enterDefaultWorkspace` as its `stillWanted`, so a late `workspace.list` reply does not pull the user back to the Default workspace. A host hit opens through `openHostHit.ts` — a file as a tab, a spec in the Specs tool, a section there scrolled to its heading line (`requestSpecView` with the line), a transcript at its message, a plan with its chat and the Plan tool — each only after the navigator resolved `true`. - Open state lives in [[submodule-web-store]] (`searchOpen` + `openSearch` / `closeSearch`) so an aggregate attention notification from [[submodule-web-notifications]] can open the palette; the host keys it on `searchSession`, so that click resets an already-open palette to its Waiting view. It opens over the first-run wizard as the topbar Settings button does, and its project commands end the wizard the way the wizard's own project cards do. The open palette is the topmost chord layer and owns its keys (`Mod+K` and double Shift close it, Escape in its own order), so the global `Mod+K` is suppressed behind it like behind any modal; plain `Ctrl+R` is swallowed, not acted upon, while it is open (it reloads browsers on Windows/Linux). The global chord accepts neither Shift nor Alt and keeps winning over Monaco's `⌘K` chord prefix. diff --git a/apps/web/src/shell/paletteCatalog.test.ts b/apps/web/src/shell/paletteCatalog.test.ts index 998edcc9db..8b23b4383c 100644 --- a/apps/web/src/shell/paletteCatalog.test.ts +++ b/apps/web/src/shell/paletteCatalog.test.ts @@ -104,6 +104,7 @@ function deps(overrides: Partial = {}): PaletteCatalogDeps { toggleLeft: noop, toggleRight: noop, toggleBottom: noop, + toggleVim: noop, showTool: noop, showFiles: noop, focusRegion: noop, @@ -140,6 +141,10 @@ describe("palette project and Settings commands", () => { expect(row("project.openPath", deps())?.title).toBe("Open a path on the host…"); }); + test("Vim mode is available as a command-palette action", () => { + expect(row("view.toggleVim", deps())?.title).toBe("Toggle Vim mode"); + }); + test("Settings shows the native shortcut only where the native menu owns one", () => { expect(row("settings.open", deps())?.shortcut).toBeUndefined(); expect(row("settings.open", deps({ nativeShortcuts: true }))?.shortcut).toEqual({ diff --git a/apps/web/src/shell/paletteCatalog.ts b/apps/web/src/shell/paletteCatalog.ts index e558226586..4f6b8e0544 100644 --- a/apps/web/src/shell/paletteCatalog.ts +++ b/apps/web/src/shell/paletteCatalog.ts @@ -109,6 +109,7 @@ export interface PaletteCatalogActions { readonly toggleLeft: () => void; readonly toggleRight: () => void; readonly toggleBottom: () => void; + readonly toggleVim: () => void; readonly showTool: (workspaceId: string, tool: PaletteTool) => void; readonly showFiles: (mode: "all" | "changed") => void; readonly focusRegion: (delta: 1 | -1) => void; @@ -373,6 +374,14 @@ function viewCommands(deps: PaletteCatalogDeps): Command[] { run: () => actions.showFiles("changed"), }); } + commands.push({ + id: "view.toggleVim", + title: t("commands.view.toggleVim"), + group: "view", + icon: RiKeyboardLine, + keywords: ["vim", "vi", "normal", "insert", "visual", "editor"], + run: actions.toggleVim, + }); commands.push({ id: "view.toggleLeft", title: t("commands.view.toggleLeft"), diff --git a/apps/web/src/shell/useSearchPalette.ts b/apps/web/src/shell/useSearchPalette.ts index 5cae1e1817..eb22a96777 100644 --- a/apps/web/src/shell/useSearchPalette.ts +++ b/apps/web/src/shell/useSearchPalette.ts @@ -1,4 +1,5 @@ import { t as globalT, useT } from "@thinkrail/locales/react"; +import { toggleActiveVimMode } from "@thinkrail/resource-renderers"; import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { openFindInFiles } from "../findInFiles"; @@ -277,6 +278,7 @@ export function useSearchPalette(options: SearchPaletteOptions): SearchPalettePr toggleLeft: () => toggleLeftSide(options.toggleProjectsHome), toggleRight: toggleRightSide, toggleBottom: toggleBottomPanel, + toggleVim: toggleActiveVimMode, showTool: (workspaceId, tool) => store().requestToolView(workspaceId, tool), showFiles, focusRegion: focusAdjacentRegion, diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 4f5b35892b..e1ba3fd9df 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -234,6 +234,37 @@ async function firstVisibleLine(page: Page) { }); } +test("read-only Vim mode switches modes and opens directional search", async ({ page }) => { + await openFixtureProject(page); + const workspace = await createWorkspaceViaDialog(page); + writeFileSync(join(workspace.worktreePath, "vim.ts"), longSource("vim")); + await revealWorkbenchTool(page, "files"); + await page.getByTestId("file-node").filter({ hasText: "vim.ts" }).dblclick(); + await expect(visibleEditor(page)).toContainText("vim0"); + + const toggle = page.getByTestId("vim-mode-toggle"); + await expect(toggle).toContainText("Vim: Off"); + await toggle.click(); + await expect(toggle).toContainText("Vim: Normal"); + + await visibleEditor(page).click(); + await page.keyboard.press("i"); + await expect(toggle).toContainText("Vim: Insert"); + await page.keyboard.press("Escape"); + await page.keyboard.press("v"); + await expect(toggle).toContainText("Vim: Visual"); + await page.keyboard.press("Escape"); + await expect(toggle).toContainText("Vim: Normal"); + + await page.keyboard.press("/"); + const search = page.getByTestId("vim-search-input"); + await expect(search).toBeFocused(); + await search.fill("vim10"); + await search.press("Enter"); + await expect(search).toHaveCount(0); + await expect(visibleEditor(page)).toContainText("vim10"); +}); + test("Monaco restores scroll across a tab switch", async ({ page }) => { await openFixtureProject(page); const workspace = await createWorkspaceViaDialog(page); diff --git a/goal-and-requirements.md b/goal-and-requirements.md index a92d336b3c..2733f50b84 100644 --- a/goal-and-requirements.md +++ b/goal-and-requirements.md @@ -94,7 +94,10 @@ What ThinkRail does, at product level; the linked spec owns the detail. and bottom tool panels, kept local to each window ([[submodule-web-shell-layout]]). The region that holds the keyboard stays at full contrast while the others are muted, and VS Code-style chords move between regions, into the chat input, Projects, Files and the terminal, from one keybinding table that the - palette also shows ([[submodule-web-keybindings]]). + palette also shows ([[submodule-web-keybindings]]). Desktop source files also offer an optional, + read-only Vim interaction layer with normal, insert and visual modes, directional search and repeat + counts; its visible toggle and palette command never make the file editable + ([[module-resource-renderers]], [[submodule-web-shell]]). - **Chats** — concurrent `pi` sessions per workspace, each with its own model, pi-reported token/cost, steering and follow-ups mid-run, skill and prompt-template autocomplete, and history search ([[submodule-web-chat]], [[submodule-server-agent]]). diff --git a/packages/locales/en.json b/packages/locales/en.json index 96f927c516..296bbb6f4b 100644 --- a/packages/locales/en.json +++ b/packages/locales/en.json @@ -506,6 +506,7 @@ "commands.view.toggleBottom": "Toggle bottom panel", "commands.view.toggleLeft": "Toggle Projects panel", "commands.view.toggleRight": "Toggle right panel", + "commands.view.toggleVim": "Toggle Vim mode", "commands.view.zoomIn": "Zoom in", "commands.view.zoomOut": "Zoom out", "commands.view.zoomReset": "Reset zoom", @@ -3047,6 +3048,13 @@ "viewer.svg.diffTitle": "Vector diff of {{path}}", "viewer.svg.flexibleSize": "Flexible size", "viewer.svg.previewTitle": "Vector preview of {{path}}", + "viewer.vim.mode.insert": "Vim: Insert", + "viewer.vim.mode.normal": "Vim: Normal", + "viewer.vim.mode.visual": "Vim: Visual", + "viewer.vim.off": "Vim: Off", + "viewer.vim.search": "Vim search", + "viewer.vim.searchPlaceholder": "Search pattern", + "viewer.vim.toggle": "Toggle Vim mode", "viewer.visualDiff.commentOnNew": "Comment on new", "viewer.visualDiff.commentOnOld": "Comment on old", "viewer.visualDiff.difference": "Difference", diff --git a/packages/resource-renderers/SPEC.md b/packages/resource-renderers/SPEC.md index 0c49983d21..145be0cb64 100644 --- a/packages/resource-renderers/SPEC.md +++ b/packages/resource-renderers/SPEC.md @@ -39,6 +39,8 @@ One barrel, `@thinkrail/resource-renderers` (`src/index.ts`): `ResourcePreferences`, `ResourceScope`, `ResourceFileReviewProps`, `ResourceReviewSendProps`, `ResourceMarkdown`, `ResourceCodeHost`, `ResourceCodeLanguage`, `ResourceMarkdownProps`, `MarkdownRehypePlugins` types; +- `toggleActiveVimMode` — a narrow host hook for the currently focused desktop Monaco source editor. Vim is + renderer-local, off by default, read-only, and never reaches the mobile Pierre view or file persistence; - `canonicalWorkspacePath` — the package's copy of the app's workspace-relative path identity (`lib`'s `projectRelativePath`: POSIX separators, no `.` or empty segments, inner `..` resolved; `apps/web/src/panels/pathIdentity.test.ts` keeps the two equal); `describeResource` and `ResourcePreview` @@ -385,6 +387,11 @@ prose styles target is unchanged; the boolean attributes the sanitizer lets thro standalone checkbox's `checked`/`disabled`) are mapped to `true` because React drops an empty-string boolean. +The desktop `thinkrail/code` Monaco view optionally exposes read-only Vim interaction. Its visible +control and the host hook toggle the local mode; `normal`, `insert`, and `visual` are UI/navigation modes, +not an editing or save path. Search and count handling stay inside the Monaco view so the renderer package +owns the keyboard contract without importing the web shell. + `thinkrail/image` renders host-backed byte URLs with fit, natural-size, button/wheel zoom, intrinsic dimensions, and byte size, always on the `.media-backdrop` transparency checkerboard (the view's image and both diff frames; the difference blend keeps a flat canvas so the checker cannot leak into the diff --git a/packages/resource-renderers/src/index.ts b/packages/resource-renderers/src/index.ts index 241243c203..aef9ec6d42 100644 --- a/packages/resource-renderers/src/index.ts +++ b/packages/resource-renderers/src/index.ts @@ -71,3 +71,4 @@ export { } from "./support/rendererMount"; export { UnplacedReviewStrip } from "./support/UnplacedReviewStrip"; export { useScrollViewState, viewStateScrollTop } from "./support/useScrollViewState"; +export { toggleActiveVimMode } from "./support/vimMode"; diff --git a/packages/resource-renderers/src/support/MonacoEditor.tsx b/packages/resource-renderers/src/support/MonacoEditor.tsx index 82051a4b4f..187c068393 100644 --- a/packages/resource-renderers/src/support/MonacoEditor.tsx +++ b/packages/resource-renderers/src/support/MonacoEditor.tsx @@ -1,4 +1,6 @@ +import { RiKeyboardLine } from "@remixicon/react"; import { useT } from "@thinkrail/locales/react"; +import { Button } from "@thinkrail/ui/button"; import { onFontsChange } from "@thinkrail/ui/fonts"; import type { editor } from "monaco-editor/editor/editor.api.js"; import { @@ -32,6 +34,12 @@ import { type MonacoReviewZoneState, type ReviewCommenting, } from "./reviewWidgets"; +import { + createVimController, + initialVimState, + registerActiveVimToggle, + type VimState, +} from "./vimMode"; function focusLine(review: SurfaceReview): number | null { const range = review.focus?.anchor.selectors.find((selector) => selector.kind === "lineRange"); @@ -86,6 +94,11 @@ export default function MonacoEditor({ threads: [], composer: null, }); + const [vimState, setVimState] = useState(initialVimState); + const [searchQuery, setSearchQuery] = useState(""); + const vimControllerRef = useRef | null>(null); + const activeVimToggleRef = useRef | null>(null); + const searchInputRef = useRef(null); useLayoutEffect(() => { latestRef.current = { @@ -114,6 +127,12 @@ export default function MonacoEditor({ options: mount.options, }; const menuIcons = decorateEditorContextMenus(codeEditor); + const vimController = createVimController(codeEditor, setVimState); + const activeVimToggle = registerActiveVimToggle(vimController.toggle); + vimControllerRef.current = vimController; + activeVimToggleRef.current = activeVimToggle; + activeVimToggle.activate(); + const focusListener = codeEditor.onDidFocusEditorText(activeVimToggle.activate); if (isEditorViewState(mount.viewState)) codeEditor.restoreViewState(mount.viewState); const threads = mount.review ? attachReviewThreads(codeEditor, setReviewZones) : null; const commenting = threads @@ -124,6 +143,11 @@ export default function MonacoEditor({ return () => { const saved = codeEditor.saveViewState(); if (saved) latestRef.current.onViewState?.(saved); + focusListener.dispose(); + activeVimToggle.dispose(); + vimController.dispose(); + vimControllerRef.current = null; + activeVimToggleRef.current = null; menuIcons.dispose(); commenting?.dispose(); threads?.dispose(); @@ -160,8 +184,22 @@ export default function MonacoEditor({ }, [options]); useEffect(() => remeasureEditorFonts(font), [font]); + useEffect(() => { + if (!vimState.searchOpen) return; + setSearchQuery(""); + searchInputRef.current?.focus(); + }, [vimState.searchOpen]); const closeComposer = () => threadsRef.current?.closeComposer(); + const vimModeLabel = + vimState.mode === "normal" + ? t("viewer.vim.mode.normal") + : vimState.mode === "insert" + ? t("viewer.vim.mode.insert") + : t("viewer.vim.mode.visual"); + const submitSearch = () => vimControllerRef.current?.submitSearch(searchQuery); + const cancelSearch = () => vimControllerRef.current?.cancelSearch(); + const layoutReviewZones = () => threadsRef.current?.layout(); useLayoutEffect(() => { @@ -204,14 +242,56 @@ export default function MonacoEditor({ }, [revealLine, revealColumn, revealLength, revealTick]); return ( - <> -
+
activeVimToggleRef.current?.activate()} + > +
+ + {vimState.searchOpen ? ( +
+ + setSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitSearch(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelSearch(); + } + }} + aria-label={t("viewer.vim.search")} + data-testid="vim-search-input" + placeholder={t("viewer.vim.searchPlaceholder")} + className="h-20 w-[256px] bg-transparent text-text-default outline-none tr-code-text" + /> +
+ ) : null} - +
); } diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts new file mode 100644 index 0000000000..8753e94709 --- /dev/null +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import type { + editor, + IKeyboardEvent, + IPosition, + IRange, + ISelection, +} from "monaco-editor/editor/editor.api.js"; +import { + createVimController, + initialVimState, + registerActiveVimToggle, + toggleActiveVimMode, + type VimState, +} from "./vimMode"; + +type KeyListener = (event: IKeyboardEvent) => void; + +function position(lineNumber: number, column: number): IPosition { + return { lineNumber, column }; +} + +function makeEditor(lines: readonly string[]) { + let current = position(1, 1); + let selection: IRange | null = null; + let listener: KeyListener | null = null; + const text = lines.join("\n"); + const offsets: number[] = []; + for (const index of lines.keys()) { + const previous = offsets[index - 1] ?? 0; + const previousLine = lines[index - 1] ?? ""; + offsets.push(index === 0 ? 0 : previous + previousLine.length + 1); + } + const model = { + getValue: () => text, + getOffsetAt: (value: IPosition) => (offsets[value.lineNumber - 1] ?? 0) + value.column - 1, + getPositionAt: (offset: number) => { + const lineNumber = offsets.findLastIndex((start) => start <= offset) + 1; + return position(lineNumber, offset - (offsets[lineNumber - 1] ?? 0) + 1); + }, + getLineCount: () => lines.length, + getLineMaxColumn: (lineNumber: number) => (lines[lineNumber - 1] ?? "").length + 1, + findMatches: (query: string) => { + const matches: Array<{ range: IRange }> = []; + for (const [lineIndex, line] of lines.entries()) { + let from = 0; + while (from <= line.length) { + const found = line.indexOf(query, from); + if (found < 0) break; + matches.push({ + range: { + startLineNumber: lineIndex + 1, + startColumn: found + 1, + endLineNumber: lineIndex + 1, + endColumn: found + query.length + 1, + getStartPosition: () => position(lineIndex + 1, found + 1), + getEndPosition: () => position(lineIndex + 1, found + query.length + 1), + } as IRange, + }); + from = found + Math.max(query.length, 1); + } + } + return matches; + }, + }; + const editor = { + onKeyDown: (next: KeyListener) => { + listener = next; + return { dispose: () => (listener = null) }; + }, + getModel: () => model, + getPosition: () => current, + getSelection: () => { + const currentSelection = selection; + if (!currentSelection) return null; + return { + ...currentSelection, + getStartPosition: () => + position(currentSelection.startLineNumber, currentSelection.startColumn), + getEndPosition: () => position(currentSelection.endLineNumber, currentSelection.endColumn), + } as unknown as ISelection; + }, + setPosition: (next: IPosition) => { + current = next; + selection = null; + }, + setSelection: (next: IRange) => { + selection = next; + current = position(next.endLineNumber, next.endColumn); + }, + revealPositionInCenter: () => {}, + revealRangeInCenter: () => {}, + focus: () => {}, + }; + return { + editor: editor as unknown as editor.IStandaloneCodeEditor, + get position() { + return current; + }, + get selection() { + return selection; + }, + emit(key: string, shiftKey = false) { + const browserEvent = { + key, + shiftKey, + ctrlKey: false, + metaKey: false, + altKey: false, + isComposing: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as KeyboardEvent; + listener?.({ browserEvent } as unknown as IKeyboardEvent); + }, + }; +} + +describe("Vim mode", () => { + test("starts disabled and cycles normal, insert and visual modes", () => { + const fake = makeEditor(["one", "two"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + + expect(state).toEqual(initialVimState()); + controller.toggle(); + expect(state).toMatchObject({ enabled: true, mode: "normal" }); + fake.emit("i"); + expect(state.mode).toBe("insert"); + fake.emit("Escape"); + fake.emit("v"); + expect(state.mode).toBe("visual"); + fake.emit("Escape"); + expect(state.mode).toBe("normal"); + controller.dispose(); + }); + + test("repeats line movement with a count", () => { + const fake = makeEditor(Array.from({ length: 20 }, (_, index) => `line ${index}`)); + const controller = createVimController(fake.editor, () => {}); + controller.toggle(); + + for (const key of ["1", "0", "j"]) fake.emit(key); + expect(fake.position).toEqual(position(11, 1)); + controller.dispose(); + }); + + test("searches forward and backward with n and Shift+N", () => { + const fake = makeEditor(["alpha", "beta", "beta"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + controller.toggle(); + fake.emit("/"); + expect(state.searchOpen).toBe(true); + controller.submitSearch("beta"); + expect(fake.position).toEqual(position(2, 5)); + fake.emit("n"); + expect(fake.position).toEqual(position(3, 5)); + fake.emit("n", true); + expect(fake.position).toEqual(position(2, 5)); + controller.dispose(); + }); + + test("palette toggle reaches active editor", () => { + const fake = makeEditor(["one"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + const registration = registerActiveVimToggle(controller.toggle); + registration.activate(); + toggleActiveVimMode(); + expect(state.enabled).toBe(true); + registration.dispose(); + controller.dispose(); + }); +}); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts new file mode 100644 index 0000000000..c9415f4631 --- /dev/null +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -0,0 +1,326 @@ +import type { editor, IKeyboardEvent, IPosition } from "monaco-editor/editor/editor.api.js"; + +export type VimMode = "normal" | "insert" | "visual"; +export type VimSearchDirection = "forward" | "backward"; + +export interface VimState { + enabled: boolean; + mode: VimMode; + searchOpen: boolean; + searchDirection: VimSearchDirection; +} + +const INITIAL_STATE: VimState = { + enabled: false, + mode: "normal", + searchOpen: false, + searchDirection: "forward", +}; + +let activeOwner: symbol | null = null; +let activeToggle: (() => void) | null = null; + +export function registerActiveVimToggle(toggle: () => void): { + activate: () => void; + dispose: () => void; +} { + const owner = Symbol("vim-editor"); + return { + activate: () => { + activeOwner = owner; + activeToggle = toggle; + }, + dispose: () => { + if (activeOwner !== owner) return; + activeOwner = null; + activeToggle = null; + }, + }; +} + +export function toggleActiveVimMode(): void { + activeToggle?.(); +} + +function positionKey(position: IPosition): number { + return position.lineNumber * 1_000_000 + position.column; +} + +function isWordCharacter(value: string | undefined): boolean { + return value !== undefined && /[A-Za-z0-9_]/u.test(value); +} + +function moveWordForward(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = model.getOffsetAt(position); + while (offset < text.length && isWordCharacter(text[offset])) offset += 1; + while (offset < text.length && !isWordCharacter(text[offset])) offset += 1; + return model.getPositionAt(offset); +} + +function moveWordBackward(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = Math.max(0, model.getOffsetAt(position) - 1); + while (offset > 0 && !isWordCharacter(text[offset])) offset -= 1; + while (offset > 0 && isWordCharacter(text[offset - 1])) offset -= 1; + return model.getPositionAt(offset); +} + +function movePosition( + model: editor.ITextModel, + position: IPosition, + key: string, + count: number, +): IPosition | null { + let next = position; + for (let index = 0; index < count; index += 1) { + if (key === "h") { + next = { lineNumber: next.lineNumber, column: Math.max(1, next.column - 1) }; + } else if (key === "l") { + next = { + lineNumber: next.lineNumber, + column: Math.min(model.getLineMaxColumn(next.lineNumber), next.column + 1), + }; + } else if (key === "j") { + const lineNumber = Math.min(model.getLineCount(), next.lineNumber + 1); + next = { + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), next.column), + }; + } else if (key === "k") { + const lineNumber = Math.max(1, next.lineNumber - 1); + next = { + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), next.column), + }; + } else if (key === "w") { + next = moveWordForward(model, next); + } else if (key === "b") { + next = moveWordBackward(model, next); + } else { + return null; + } + } + return next; +} + +function clampCount(value: string): number { + const count = Number.parseInt(value, 10); + return Number.isSafeInteger(count) && count > 0 ? Math.min(count, 9999) : 1; +} + +function consume(event: IKeyboardEvent): void { + event.browserEvent.preventDefault(); + event.browserEvent.stopPropagation(); +} + +export function createVimController( + codeEditor: editor.IStandaloneCodeEditor, + onStateChange: (state: VimState) => void, +): { + toggle: () => void; + submitSearch: (query: string) => void; + cancelSearch: () => void; + dispose: () => void; +} { + let state = INITIAL_STATE; + let count = ""; + let anchor: IPosition | null = null; + let lastSearch: { query: string; direction: VimSearchDirection } | null = null; + + const update = (patch: Partial) => { + state = { ...state, ...patch }; + onStateChange(state); + }; + + const setPosition = (position: IPosition) => { + codeEditor.setPosition(position); + codeEditor.revealPositionInCenter(position); + }; + + const setVisualPosition = (position: IPosition) => { + if (!anchor) anchor = codeEditor.getPosition() ?? position; + codeEditor.setSelection({ + selectionStartLineNumber: anchor.lineNumber, + selectionStartColumn: anchor.column, + positionLineNumber: position.lineNumber, + positionColumn: position.column, + }); + codeEditor.revealPositionInCenter(position); + }; + + const performSearch = (query: string, direction: VimSearchDirection, repetitions = 1) => { + const model = codeEditor.getModel(); + const current = codeEditor.getPosition(); + if (!model || !current || query.length === 0) return; + const selection = codeEditor.getSelection(); + let searchPosition = + direction === "forward" + ? (selection?.getEndPosition() ?? current) + : (selection?.getStartPosition() ?? current); + const matches = model.findMatches(query, false, false, false, null, false); + if (matches.length === 0) return; + for (let index = 0; index < repetitions; index += 1) { + const currentOffset = positionKey(searchPosition); + const match = + direction === "forward" + ? (matches.find( + (candidate) => positionKey(candidate.range.getStartPosition()) > currentOffset, + ) ?? matches[0]) + : (matches.findLast( + (candidate) => positionKey(candidate.range.getStartPosition()) < currentOffset, + ) ?? matches[matches.length - 1]); + if (!match) return; + searchPosition = + direction === "forward" ? match.range.getEndPosition() : match.range.getStartPosition(); + codeEditor.setSelection(match.range); + codeEditor.revealRangeInCenter(match.range); + } + lastSearch = { query, direction }; + }; + + const submitSearch = (query: string) => { + update({ searchOpen: false }); + performSearch(query, state.searchDirection); + codeEditor.focus(); + }; + + const cancelSearch = () => { + update({ searchOpen: false }); + codeEditor.focus(); + }; + + const toggle = () => { + const enabled = !state.enabled; + count = ""; + anchor = null; + update({ enabled, mode: "normal", searchOpen: false }); + codeEditor.focus(); + }; + + const onKeyDown = (event: IKeyboardEvent) => { + if (!state.enabled || event.browserEvent.isComposing) return; + const key = event.browserEvent.key; + if (event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey) { + if (key !== "Escape") return; + } + + if (state.mode === "insert") { + if (key !== "Escape") return; + consume(event); + update({ mode: "normal" }); + return; + } + + if (key === "Escape") { + consume(event); + count = ""; + anchor = null; + update({ mode: "normal" }); + return; + } + + if (key >= "1" && key <= "9") { + consume(event); + count += key; + return; + } + if (key === "0" && count.length > 0) { + consume(event); + count += key; + return; + } + + if (key === "/" || key === "?") { + consume(event); + count = ""; + update({ searchOpen: true, searchDirection: key === "/" ? "forward" : "backward" }); + return; + } + + if (key === "i" || key === "a") { + consume(event); + count = ""; + update({ mode: "insert" }); + return; + } + + if (key === "v") { + consume(event); + count = ""; + anchor = codeEditor.getPosition(); + update({ mode: "visual" }); + return; + } + + if (key === "n" && lastSearch) { + consume(event); + const repetitions = clampCount(count); + count = ""; + performSearch( + lastSearch.query, + event.browserEvent.shiftKey + ? lastSearch.direction === "forward" + ? "backward" + : "forward" + : lastSearch.direction, + repetitions, + ); + return; + } + + if (key === "G") { + consume(event); + const model = codeEditor.getModel(); + if (model) { + const lineNumber = + count.length > 0 + ? Math.min(model.getLineCount(), clampCount(count)) + : model.getLineCount(); + const position = { lineNumber, column: 1 }; + if (state.mode === "visual") setVisualPosition(position); + else setPosition(position); + } + count = ""; + return; + } + + if (key === "0" || key === "$") { + consume(event); + const position = codeEditor.getPosition(); + const model = codeEditor.getModel(); + if (position && model) { + const next = { + lineNumber: position.lineNumber, + column: key === "0" ? 1 : model.getLineMaxColumn(position.lineNumber), + }; + if (state.mode === "visual") setVisualPosition(next); + else setPosition(next); + } + count = ""; + return; + } + + const model = codeEditor.getModel(); + const position = codeEditor.getPosition(); + if (!model || !position) return; + const next = movePosition(model, position, key, clampCount(count)); + if (!next) return; + consume(event); + count = ""; + if (state.mode === "visual") setVisualPosition(next); + else setPosition(next); + }; + + const listener = codeEditor.onKeyDown(onKeyDown); + return { + toggle, + submitSearch, + cancelSearch, + dispose: () => listener.dispose(), + }; +} + +export function initialVimState(): VimState { + return { ...INITIAL_STATE }; +} From 0f81161284f0bddc327d5b160b91d2d676a857eb Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 01:47:13 +0100 Subject: [PATCH 09/14] fix(web): handle shifted Vim search navigation --- packages/resource-renderers/src/support/vimMode.test.ts | 2 +- packages/resource-renderers/src/support/vimMode.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 8753e94709..02548831b4 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -156,7 +156,7 @@ describe("Vim mode", () => { expect(fake.position).toEqual(position(2, 5)); fake.emit("n"); expect(fake.position).toEqual(position(3, 5)); - fake.emit("n", true); + fake.emit("N", true); expect(fake.position).toEqual(position(2, 5)); controller.dispose(); }); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index c9415f4631..fe1d9d6595 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -201,6 +201,7 @@ export function createVimController( const onKeyDown = (event: IKeyboardEvent) => { if (!state.enabled || event.browserEvent.isComposing) return; const key = event.browserEvent.key; + const lowerKey = key.toLowerCase(); if (event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey) { if (key !== "Escape") return; } @@ -253,7 +254,7 @@ export function createVimController( return; } - if (key === "n" && lastSearch) { + if (lowerKey === "n" && lastSearch) { consume(event); const repetitions = clampCount(count); count = ""; From a7fe6ef13078547f04aee6038ea14c3d3125a0ba Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 13:52:54 +0100 Subject: [PATCH 10/14] feat(web): expand read-only Vim navigation --- CHANGELOG.md | 11 + README.md | 2 + packages/resource-renderers/SPEC.md | 7 +- .../src/support/vimMode.test.ts | 68 +++ .../resource-renderers/src/support/vimMode.ts | 451 ++++++++++++++++-- 5 files changed, 503 insertions(+), 36 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..742e749874 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Unreleased + +### Added + +- Expanded optional read-only Vim mode in desktop Monaco source editors toward `vim.tiny` navigation parity. + - Toggle with the visible `Vim` control or the command palette's **Toggle Vim mode** command. + - Supports `Normal`, `Insert`, and `Visual` modes, including character, line, and block selection. + - Supports core motions, marks, jump history, viewport commands, `/` and `?` searches, `n`, `Shift+N`, `*`, `#`, and repeat counts such as `10j`. + - Mutation commands, registers, clipboard, Ex commands, undo, and file saves remain unavailable because the editor is read-only. diff --git a/README.md b/README.md index bda8e51912..a7d6449e45 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ For latest version, see the [Releases page](https://github.com/JetBrains/thinkra - **An IDE around the agent.** ThinkRail comes with Monaco editor tabs, a file tree integrated with git to show code changes as they happen, terminals, and document support. Rendered Markdown previews have a heading outline that follows the section you're reading; hide it or move it to either side. The pane you're typing in stays at full contrast while the rest is dimmed, and VS Code-style keys move you around: `F6` steps between panes, `⌘L` jumps to the chat input, `⌘0` to Projects, `⌘1`–`⌘9` to an editor group, and `` ⌃` `` to the terminal; `⌘\` splits the editor and `⌃⌘←` / `⌃⌘→` move a tab between groups. +- **Read-only Vim mode.** In a desktop Monaco source editor, turn Vim on with the `Vim` control or the command palette's **Toggle Vim mode** command. `Normal` supports `h`/`j`/`k`/`l`, `w`/`b`/`e`, `0`/`^`/`$`, `gg`/`G`, `f`/`F`/`t`/`T`, `%`, `H`/`M`/`L`, `zt`/`zz`/`zb`, marks, jump history, and repeat counts such as `10j`. `Visual` supports character (`v`), line (`V`), block (`Ctrl+V`), and `o`/`O` endpoint changes; `Insert` remains a read-only mode. Press `/` to search forward or `?` backward, `n` for the next match, `Shift+N` for the previous one, and `*`/`#` for the word under the cursor. Mutation commands, registers, clipboard, Ex commands, undo, and file saves remain unavailable. + - **Powerful chat features.** You can open several concurrent work sessions per workspace, each with its own model. Send a prompt, or queue it as a follow-up, delegate to subagents, and work together on a shared plan: the agent updates it, and you review and edit. - **Find anything from one box.** `⌘K` (or a double tap of Shift) opens a palette that finds chats and the messages inside them, workspaces and projects, files in the current workspace, specs, and plans, with a preview pane. It also runs the app's commands, including the active tab's layout commands (split, move to another group or pane, new or remove group, close), and opens Settings sections, and with nothing typed it lists the chats waiting for you. Type a task and press `⌘↵` to open Create workspace with it as the prompt. Press `⌘⇧F` to find text inside the workspace's files as you type: matches are grouped by file, with match-case and whole-word toggles and a code preview, and `↵` opens the file at that line. diff --git a/packages/resource-renderers/SPEC.md b/packages/resource-renderers/SPEC.md index 145be0cb64..63ae714b1e 100644 --- a/packages/resource-renderers/SPEC.md +++ b/packages/resource-renderers/SPEC.md @@ -389,8 +389,11 @@ boolean. The desktop `thinkrail/code` Monaco view optionally exposes read-only Vim interaction. Its visible control and the host hook toggle the local mode; `normal`, `insert`, and `visual` are UI/navigation modes, -not an editing or save path. Search and count handling stay inside the Monaco view so the renderer package -owns the keyboard contract without importing the web shell. +not an editing or save path. The supported read-only subset follows the high-value core exposed by compact +Linux Vim builds: word/line/character/bracket motions, `gg`/`G`, viewport motions, marks and jump history, +character/line/block Visual selection, search and repeat counts. Mutation operators, registers, clipboard, +Ex commands, undo/redo and macros remain outside the contract. Search and count handling stay inside the +Monaco view so the renderer package owns the keyboard contract without importing the web shell. `thinkrail/image` renders host-backed byte URLs with fit, natural-size, button/wheel zoom, intrinsic dimensions, and byte size, always on the `.media-backdrop` transparency checkerboard (the view's image diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 02548831b4..806cb3a2d2 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -39,7 +39,20 @@ function makeEditor(lines: readonly string[]) { return position(lineNumber, offset - (offsets[lineNumber - 1] ?? 0) + 1); }, getLineCount: () => lines.length, + getLineContent: (lineNumber: number) => lines[lineNumber - 1] ?? "", getLineMaxColumn: (lineNumber: number) => (lines[lineNumber - 1] ?? "").length + 1, + getWordAtPosition: (value: IPosition) => { + const line = lines[value.lineNumber - 1] ?? ""; + const index = value.column - 1; + const match = /[A-Za-z0-9_]+/gu; + for (const item of line.matchAll(match)) { + const start = item.index ?? 0; + if (index >= start && index <= start + item[0].length) { + return { word: item[0], startColumn: start + 1, endColumn: start + item[0].length + 1 }; + } + } + return null; + }, findMatches: (query: string) => { const matches: Array<{ range: IRange }> = []; for (const [lineIndex, line] of lines.entries()) { @@ -88,8 +101,24 @@ function makeEditor(lines: readonly string[]) { selection = next; current = position(next.endLineNumber, next.endColumn); }, + setSelections: (next: readonly ISelection[]) => { + const last = next[next.length - 1]; + if (!last) return; + selection = last as unknown as IRange; + current = position(last.positionLineNumber, last.positionColumn); + }, + getVisibleRanges: () => [ + { + startLineNumber: 1, + startColumn: 1, + endLineNumber: Math.min(lines.length, 5), + endColumn: 1, + } as IRange, + ], revealPositionInCenter: () => {}, revealRangeInCenter: () => {}, + revealLineNearTop: () => {}, + revealLineInCenter: () => {}, focus: () => {}, }; return { @@ -161,6 +190,45 @@ describe("Vim mode", () => { controller.dispose(); }); + test("supports Vim tiny navigation, Visual variants, marks and viewport jumps", () => { + const fake = makeEditor([" alpha beta", "(gamma)", "delta"]); + let state: VimState = initialVimState(); + const controller = createVimController(fake.editor, (next) => (state = next)); + controller.toggle(); + + fake.emit("^"); + expect(fake.position).toEqual(position(1, 3)); + fake.emit("e"); + expect(fake.position).toEqual(position(1, 7)); + fake.emit("f"); + fake.emit("b"); + expect(fake.position).toEqual(position(1, 9)); + fake.emit("G"); + expect(fake.position.lineNumber).toBe(3); + fake.emit("g"); + fake.emit("g"); + expect(fake.position.lineNumber).toBe(1); + + fake.emit("m"); + fake.emit("a"); + fake.emit("G"); + fake.emit("`"); + fake.emit("a"); + expect(fake.position.lineNumber).toBe(1); + + fake.emit("V"); + expect(state.visualMode).toBe("line"); + fake.emit("Escape"); + fake.emit("v"); + expect(state.visualMode).toBe("character"); + fake.emit("Escape"); + fake.emit("v"); + fake.emit("V", true); + expect(state.visualMode).toBe("line"); + fake.emit("Escape"); + controller.dispose(); + }); + test("palette toggle reaches active editor", () => { const fake = makeEditor(["one"]); let state: VimState = initialVimState(); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index fe1d9d6595..52adb1c2d8 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -1,11 +1,18 @@ -import type { editor, IKeyboardEvent, IPosition } from "monaco-editor/editor/editor.api.js"; +import type { + editor, + IKeyboardEvent, + IPosition, + ISelection, +} from "monaco-editor/editor/editor.api.js"; export type VimMode = "normal" | "insert" | "visual"; +export type VimVisualMode = "character" | "line" | "block"; export type VimSearchDirection = "forward" | "backward"; export interface VimState { enabled: boolean; mode: VimMode; + visualMode: VimVisualMode | null; searchOpen: boolean; searchDirection: VimSearchDirection; } @@ -13,10 +20,20 @@ export interface VimState { const INITIAL_STATE: VimState = { enabled: false, mode: "normal", + visualMode: null, searchOpen: false, searchDirection: "forward", }; +const BRACKET_PAIRS: Readonly> = { + "(": ")", + ")": "(", + "[": "]", + "]": "[", + "{": "}", + "}": "{", +}; + let activeOwner: symbol | null = null; let activeToggle: (() => void) | null = null; @@ -66,6 +83,14 @@ function moveWordBackward(model: editor.ITextModel, position: IPosition): IPosit return model.getPositionAt(offset); } +function moveWordEnd(model: editor.ITextModel, position: IPosition): IPosition { + const text = model.getValue(); + let offset = model.getOffsetAt(position); + while (offset < text.length && !isWordCharacter(text[offset])) offset += 1; + while (offset < text.length && isWordCharacter(text[offset + 1])) offset += 1; + return model.getPositionAt(offset); +} + function movePosition( model: editor.ITextModel, position: IPosition, @@ -97,6 +122,8 @@ function movePosition( next = moveWordForward(model, next); } else if (key === "b") { next = moveWordBackward(model, next); + } else if (key === "e") { + next = moveWordEnd(model, next); } else { return null; } @@ -114,6 +141,91 @@ function consume(event: IKeyboardEvent): void { event.browserEvent.stopPropagation(); } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function lineStart( + model: editor.ITextModel, + lineNumber: number, + firstNonBlank: boolean, +): IPosition { + if (!firstNonBlank) return { lineNumber, column: 1 }; + const match = model.getLineContent(lineNumber).search(/\S/u); + return { lineNumber, column: match < 0 ? 1 : match + 1 }; +} + +function findCharacter( + model: editor.ITextModel, + position: IPosition, + character: string, + direction: "forward" | "backward", + before: boolean, + count: number, +): IPosition { + let current = position; + for (let repetition = 0; repetition < count; repetition += 1) { + const line = model.getLineContent(current.lineNumber); + const start = direction === "forward" ? current.column : current.column - 2; + const found = + direction === "forward" + ? line.indexOf(character, Math.max(0, start)) + : line.lastIndexOf(character, Math.max(0, start)); + if (found < 0) return position; + const column = found + 1 + (before ? (direction === "forward" ? -1 : 1) : 0); + current = { lineNumber: current.lineNumber, column: Math.max(1, column) }; + } + return current; +} + +function matchingBracket(model: editor.ITextModel, position: IPosition): IPosition | null { + const text = model.getValue(); + const offset = model.getOffsetAt(position); + let index = offset; + let character = text[index]; + if (!BRACKET_PAIRS[character ?? ""]) { + index = offset + 1; + while (index < text.length && !BRACKET_PAIRS[text[index] ?? ""]) index += 1; + character = text[index]; + } + const pair = BRACKET_PAIRS[character ?? ""]; + if (!pair) return null; + const forward = character === "(" || character === "[" || character === "{"; + let depth = 0; + for ( + let cursor = index; + forward ? cursor < text.length : cursor >= 0; + cursor += forward ? 1 : -1 + ) { + const current = text[cursor]; + if (current === character) depth += 1; + if (current === pair) { + depth -= 1; + if (depth === 0) return model.getPositionAt(cursor); + } + } + return null; +} + +function visibleLine( + codeEditor: editor.IStandaloneCodeEditor, + kind: "top" | "middle" | "bottom", + count: number, +): number | null { + const range = codeEditor.getVisibleRanges()[0]; + if (!range) return null; + if (kind === "top") return Math.min(range.startLineNumber + count - 1, range.endLineNumber); + if (kind === "bottom") return Math.max(range.endLineNumber - count + 1, range.startLineNumber); + return Math.floor((range.startLineNumber + range.endLineNumber) / 2); +} + +type PendingCommand = + | { kind: "character"; direction: "forward" | "backward"; before: boolean; count: number } + | { kind: "g"; count: number } + | { kind: "z"; count: number } + | { kind: "mark-set" } + | { kind: "mark-jump"; exact: boolean }; + export function createVimController( codeEditor: editor.IStandaloneCodeEditor, onStateChange: (state: VimState) => void, @@ -126,20 +238,77 @@ export function createVimController( let state = INITIAL_STATE; let count = ""; let anchor: IPosition | null = null; - let lastSearch: { query: string; direction: VimSearchDirection } | null = null; + let pending: PendingCommand | null = null; + let lastSearch: { query: string; direction: VimSearchDirection; wholeWord: boolean } | null = + null; + const marks = new Map(); + let jumpHistory: IPosition[] = []; + let jumpIndex = -1; const update = (patch: Partial) => { state = { ...state, ...patch }; onStateChange(state); }; - const setPosition = (position: IPosition) => { + const currentPosition = () => codeEditor.getPosition(); + + const recordJump = (target: IPosition) => { + const current = currentPosition(); + if (!current) return; + if ( + jumpIndex >= 0 && + jumpHistory[jumpIndex]?.lineNumber === current.lineNumber && + jumpHistory[jumpIndex]?.column === current.column + ) { + jumpHistory = jumpHistory.slice(0, jumpIndex + 1); + } else { + jumpHistory = jumpHistory.slice(0, jumpIndex + 1); + jumpHistory.push(current); + } + jumpHistory.push(target); + jumpIndex = jumpHistory.length - 1; + }; + + const setPosition = (position: IPosition, jump = false) => { + if (jump) recordJump(position); codeEditor.setPosition(position); codeEditor.revealPositionInCenter(position); }; const setVisualPosition = (position: IPosition) => { - if (!anchor) anchor = codeEditor.getPosition() ?? position; + if (!anchor) anchor = currentPosition() ?? position; + if (state.visualMode === "line") { + const model = codeEditor.getModel(); + const end = Math.max(anchor.lineNumber, position.lineNumber); + const anchorEnd = model?.getLineMaxColumn(anchor.lineNumber) ?? anchor.column; + const positionEnd = model?.getLineMaxColumn(position.lineNumber) ?? position.column; + codeEditor.setSelection({ + selectionStartLineNumber: anchor.lineNumber, + selectionStartColumn: anchor.lineNumber <= position.lineNumber ? 1 : anchorEnd, + positionLineNumber: position.lineNumber, + positionColumn: position.lineNumber >= anchor.lineNumber ? positionEnd : 1, + }); + codeEditor.revealLineInCenter(end); + return; + } + if (state.visualMode === "block") { + const startLine = Math.min(anchor.lineNumber, position.lineNumber); + const endLine = Math.max(anchor.lineNumber, position.lineNumber); + const startColumn = Math.min(anchor.column, position.column); + const endColumn = Math.max(anchor.column, position.column); + const selections: ISelection[] = []; + for (let line = startLine; line <= endLine; line += 1) { + selections.push({ + selectionStartLineNumber: line, + selectionStartColumn: startColumn, + positionLineNumber: line, + positionColumn: endColumn, + }); + } + codeEditor.setSelections(selections); + codeEditor.revealPositionInCenter(position); + return; + } codeEditor.setSelection({ selectionStartLineNumber: anchor.lineNumber, selectionStartColumn: anchor.column, @@ -149,17 +318,29 @@ export function createVimController( codeEditor.revealPositionInCenter(position); }; - const performSearch = (query: string, direction: VimSearchDirection, repetitions = 1) => { + const applyPosition = (position: IPosition, jump = false) => { + if (state.mode === "visual") setVisualPosition(position); + else setPosition(position, jump); + }; + + const performSearch = ( + query: string, + direction: VimSearchDirection, + repetitions = 1, + wholeWord = false, + ) => { const model = codeEditor.getModel(); - const current = codeEditor.getPosition(); + const current = currentPosition(); if (!model || !current || query.length === 0) return; const selection = codeEditor.getSelection(); let searchPosition = direction === "forward" ? (selection?.getEndPosition() ?? current) : (selection?.getStartPosition() ?? current); - const matches = model.findMatches(query, false, false, false, null, false); + const searchQuery = wholeWord ? `\\b${escapeRegex(query)}\\b` : query; + const matches = model.findMatches(searchQuery, false, wholeWord, false, null, false); if (matches.length === 0) return; + recordJump(matches[0]?.range.getStartPosition() ?? current); for (let index = 0; index < repetitions; index += 1) { const currentOffset = positionKey(searchPosition); const match = @@ -176,7 +357,16 @@ export function createVimController( codeEditor.setSelection(match.range); codeEditor.revealRangeInCenter(match.range); } - lastSearch = { query, direction }; + lastSearch = { query, direction, wholeWord }; + }; + + const jumpHistoryBy = (delta: -1 | 1) => { + if (jumpHistory.length === 0) return; + const nextIndex = Math.max(0, Math.min(jumpHistory.length - 1, jumpIndex + delta)); + if (nextIndex === jumpIndex) return; + jumpIndex = nextIndex; + const position = jumpHistory[jumpIndex]; + if (position) setPosition(position); }; const submitSearch = (query: string) => { @@ -194,7 +384,8 @@ export function createVimController( const enabled = !state.enabled; count = ""; anchor = null; - update({ enabled, mode: "normal", searchOpen: false }); + pending = null; + update({ enabled, mode: "normal", visualMode: null, searchOpen: false }); codeEditor.focus(); }; @@ -202,14 +393,13 @@ export function createVimController( if (!state.enabled || event.browserEvent.isComposing) return; const key = event.browserEvent.key; const lowerKey = key.toLowerCase(); - if (event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey) { - if (key !== "Escape") return; - } + const hasModifier = + event.browserEvent.ctrlKey || event.browserEvent.metaKey || event.browserEvent.altKey; if (state.mode === "insert") { if (key !== "Escape") return; consume(event); - update({ mode: "normal" }); + update({ mode: "normal", visualMode: null }); return; } @@ -217,7 +407,111 @@ export function createVimController( consume(event); count = ""; anchor = null; - update({ mode: "normal" }); + pending = null; + update({ mode: "normal", visualMode: null }); + return; + } + + if ( + hasModifier && + event.browserEvent.ctrlKey && + (lowerKey === "v" || + lowerKey === "o" || + lowerKey === "i" || + lowerKey === "d" || + lowerKey === "u" || + lowerKey === "f" || + lowerKey === "b") + ) { + if (lowerKey === "v") { + consume(event); + anchor = currentPosition(); + update({ mode: "visual", visualMode: "block" }); + return; + } + if (lowerKey === "o" || lowerKey === "i") { + consume(event); + jumpHistoryBy(lowerKey === "o" ? -1 : 1); + return; + } + const visible = codeEditor.getVisibleRanges()[0]; + const current = currentPosition(); + if (visible && current) { + const page = Math.max(1, visible.endLineNumber - visible.startLineNumber); + const delta = + lowerKey === "f" + ? page + : lowerKey === "b" + ? -page + : lowerKey === "d" + ? Math.floor(page / 2) + : lowerKey === "u" + ? -Math.floor(page / 2) + : 0; + if (delta !== 0) { + consume(event); + const model = codeEditor.getModel(); + if (model) { + const lineNumber = Math.max( + 1, + Math.min(model.getLineCount(), current.lineNumber + delta), + ); + setPosition({ + lineNumber, + column: Math.min(model.getLineMaxColumn(lineNumber), current.column), + }); + } + return; + } + } + } + + if (pending) { + const command = pending; + pending = null; + if (command.kind === "character" && key.length === 1) { + const model = codeEditor.getModel(); + const position = currentPosition(); + if (model && position) + applyPosition( + findCharacter(model, position, key, command.direction, command.before, command.count), + ); + consume(event); + return; + } + if (command.kind === "g" && lowerKey === "g") { + const model = codeEditor.getModel(); + if (model) applyPosition(lineStart(model, command.count, false)); + consume(event); + return; + } + if (command.kind === "z" && (lowerKey === "t" || lowerKey === "z" || lowerKey === "b")) { + const line = currentPosition()?.lineNumber ?? null; + if (line !== null) { + if (lowerKey === "t") codeEditor.revealLineNearTop(line); + else codeEditor.revealLineInCenter(line); + } + consume(event); + return; + } + if (command.kind === "mark-set" && /^[a-z]$/u.test(key)) { + const position = currentPosition(); + if (position) marks.set(key, position); + consume(event); + return; + } + if (command.kind === "mark-jump" && /^[a-z]$/u.test(key)) { + const mark = marks.get(key); + if (mark) { + const model = codeEditor.getModel(); + setPosition( + command.exact ? mark : model ? lineStart(model, mark.lineNumber, false) : mark, + true, + ); + } + consume(event); + return; + } return; } @@ -239,18 +533,29 @@ export function createVimController( return; } - if (key === "i" || key === "a") { + if (key === "i" || key === "a" || key === "A") { consume(event); count = ""; - update({ mode: "insert" }); + update({ mode: "insert", visualMode: null }); return; } - if (key === "v") { + if (key === "v" || key === "V") { consume(event); count = ""; - anchor = codeEditor.getPosition(); - update({ mode: "visual" }); + anchor = currentPosition(); + update({ mode: "visual", visualMode: key === "V" ? "line" : "character" }); + return; + } + + if (state.mode === "visual" && (key === "o" || key === "O")) { + const position = currentPosition(); + if (position && anchor) { + const previousAnchor = anchor; + anchor = position; + setVisualPosition(previousAnchor); + } + consume(event); return; } @@ -266,10 +571,62 @@ export function createVimController( : "forward" : lastSearch.direction, repetitions, + lastSearch.wholeWord, ); return; } + if (key === "*" || key === "#") { + const model = codeEditor.getModel(); + const position = currentPosition(); + const word = model && position ? model.getWordAtPosition(position)?.word : null; + if (word) { + consume(event); + performSearch(word, key === "*" ? "forward" : "backward", clampCount(count), true); + } + count = ""; + return; + } + + if (key === "f" || key === "F" || key === "t" || key === "T") { + consume(event); + pending = { + kind: "character", + direction: key === "f" || key === "t" ? "forward" : "backward", + before: key === "t" || key === "T", + count: clampCount(count), + }; + count = ""; + return; + } + + if (key === "g") { + consume(event); + pending = { kind: "g", count: clampCount(count) }; + count = ""; + return; + } + + if (key === "z") { + consume(event); + pending = { kind: "z", count: clampCount(count) }; + count = ""; + return; + } + + if (key === "m") { + consume(event); + pending = { kind: "mark-set" }; + count = ""; + return; + } + if (key === "'" || key === "`") { + consume(event); + pending = { kind: "mark-jump", exact: key === "`" }; + count = ""; + return; + } + if (key === "G") { consume(event); const model = codeEditor.getModel(); @@ -278,39 +635,65 @@ export function createVimController( count.length > 0 ? Math.min(model.getLineCount(), clampCount(count)) : model.getLineCount(); - const position = { lineNumber, column: 1 }; - if (state.mode === "visual") setVisualPosition(position); - else setPosition(position); + applyPosition(lineStart(model, lineNumber, false), true); } count = ""; return; } - if (key === "0" || key === "$") { + if (key === "^" || key === "0" || key === "$") { consume(event); - const position = codeEditor.getPosition(); + const position = currentPosition(); const model = codeEditor.getModel(); if (position && model) { - const next = { - lineNumber: position.lineNumber, - column: key === "0" ? 1 : model.getLineMaxColumn(position.lineNumber), - }; - if (state.mode === "visual") setVisualPosition(next); - else setPosition(next); + const next = + key === "$" + ? { + lineNumber: position.lineNumber, + column: model.getLineMaxColumn(position.lineNumber), + } + : lineStart(model, position.lineNumber, key === "^"); + applyPosition(next); + } + count = ""; + return; + } + + if (key === "%") { + const model = codeEditor.getModel(); + const position = currentPosition(); + const match = model && position ? matchingBracket(model, position) : null; + if (match) { + consume(event); + applyPosition(match, true); + } + count = ""; + return; + } + + if (key === "H" || key === "M" || key === "L") { + const line = visibleLine( + codeEditor, + key === "H" ? "top" : key === "L" ? "bottom" : "middle", + clampCount(count), + ); + const model = codeEditor.getModel(); + if (line !== null && model) { + consume(event); + applyPosition(lineStart(model, line, false)); } count = ""; return; } const model = codeEditor.getModel(); - const position = codeEditor.getPosition(); + const position = currentPosition(); if (!model || !position) return; - const next = movePosition(model, position, key, clampCount(count)); + const next = movePosition(model, position, lowerKey, clampCount(count)); if (!next) return; consume(event); count = ""; - if (state.mode === "visual") setVisualPosition(next); - else setPosition(next); + applyPosition(next); }; const listener = codeEditor.onKeyDown(onKeyDown); From f17f3196fa405a2ec5a2cf6fa4f34c1624dd8aa1 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 14:26:30 +0100 Subject: [PATCH 11/14] fix(web): address Vim review feedback --- .../src/support/MonacoEditor.tsx | 2 +- .../src/support/vimMode.test.ts | 31 +++++++++++++++++-- .../resource-renderers/src/support/vimMode.ts | 29 +++++++++++------ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/resource-renderers/src/support/MonacoEditor.tsx b/packages/resource-renderers/src/support/MonacoEditor.tsx index 187c068393..54b455ae76 100644 --- a/packages/resource-renderers/src/support/MonacoEditor.tsx +++ b/packages/resource-renderers/src/support/MonacoEditor.tsx @@ -282,7 +282,7 @@ export default function MonacoEditor({ aria-label={t("viewer.vim.search")} data-testid="vim-search-input" placeholder={t("viewer.vim.searchPlaceholder")} - className="h-20 w-[256px] bg-transparent text-text-default outline-none tr-code-text" + className="h-20 w-[256px] bg-transparent text-text-default outline-none focus-visible:focus-ring tr-code-text" />
) : null} diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 806cb3a2d2..2db1550688 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -23,6 +23,7 @@ function position(lineNumber: number, column: number): IPosition { function makeEditor(lines: readonly string[]) { let current = position(1, 1); let selection: IRange | null = null; + let scrollTop = 0; let listener: KeyListener | null = null; const text = lines.join("\n"); const offsets: number[] = []; @@ -115,6 +116,11 @@ function makeEditor(lines: readonly string[]) { endColumn: 1, } as IRange, ], + getTopForLineNumber: (lineNumber: number) => (lineNumber - 1) * 20, + getLayoutInfo: () => ({ height: 100 }), + setScrollTop: (next: number) => { + scrollTop = next; + }, revealPositionInCenter: () => {}, revealRangeInCenter: () => {}, revealLineNearTop: () => {}, @@ -129,11 +135,14 @@ function makeEditor(lines: readonly string[]) { get selection() { return selection; }, - emit(key: string, shiftKey = false) { + get scrollTop() { + return scrollTop; + }, + emit(key: string, shiftKey = false, ctrlKey = false) { const browserEvent = { key, shiftKey, - ctrlKey: false, + ctrlKey, metaKey: false, altKey: false, isComposing: false, @@ -203,8 +212,24 @@ describe("Vim mode", () => { fake.emit("f"); fake.emit("b"); expect(fake.position).toEqual(position(1, 9)); + const counted = makeEditor(["a b c d"]); + const countedController = createVimController(counted.editor, () => {}); + countedController.toggle(); + counted.emit("2"); + counted.emit("t"); + counted.emit(" "); + expect(counted.position).toEqual(position(1, 3)); + countedController.dispose(); fake.emit("G"); expect(fake.position.lineNumber).toBe(3); + const viewport = makeEditor(Array.from({ length: 20 }, (_, index) => `line ${index}`)); + const viewportController = createVimController(viewport.editor, () => {}); + viewportController.toggle(); + viewport.emit("G"); + viewport.emit("z"); + viewport.emit("b"); + expect(viewport.scrollTop).toBeGreaterThan(0); + viewportController.dispose(); fake.emit("g"); fake.emit("g"); expect(fake.position.lineNumber).toBe(1); @@ -218,6 +243,8 @@ describe("Vim mode", () => { fake.emit("V"); expect(state.visualMode).toBe("line"); + fake.emit("a", false, true); + expect(state.mode).toBe("visual"); fake.emit("Escape"); fake.emit("v"); expect(state.visualMode).toBe("character"); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index 52adb1c2d8..47f501057e 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -163,19 +163,19 @@ function findCharacter( before: boolean, count: number, ): IPosition { - let current = position; + const line = model.getLineContent(position.lineNumber); + let cursor = direction === "forward" ? position.column : position.column - 2; + let found = -1; for (let repetition = 0; repetition < count; repetition += 1) { - const line = model.getLineContent(current.lineNumber); - const start = direction === "forward" ? current.column : current.column - 2; - const found = + found = direction === "forward" - ? line.indexOf(character, Math.max(0, start)) - : line.lastIndexOf(character, Math.max(0, start)); + ? line.indexOf(character, Math.max(0, cursor)) + : line.lastIndexOf(character, Math.max(0, cursor)); if (found < 0) return position; - const column = found + 1 + (before ? (direction === "forward" ? -1 : 1) : 0); - current = { lineNumber: current.lineNumber, column: Math.max(1, column) }; + cursor = direction === "forward" ? found + 1 : found - 1; } - return current; + const column = found + 1 + (before ? (direction === "forward" ? -1 : 1) : 0); + return { lineNumber: position.lineNumber, column: Math.max(1, column) }; } function matchingBracket(model: editor.ITextModel, position: IPosition): IPosition | null { @@ -207,6 +207,14 @@ function matchingBracket(model: editor.ITextModel, position: IPosition): IPositi return null; } +function revealLineNearBottom(codeEditor: editor.IStandaloneCodeEditor, lineNumber: number): void { + const lineTop = codeEditor.getTopForLineNumber(lineNumber); + const nextLineTop = codeEditor.getTopForLineNumber(lineNumber + 1); + const lineHeight = Math.max(1, nextLineTop - lineTop); + const viewportHeight = codeEditor.getLayoutInfo().height; + codeEditor.setScrollTop(Math.max(0, lineTop - viewportHeight + lineHeight)); +} + function visibleLine( codeEditor: editor.IStandaloneCodeEditor, kind: "top" | "middle" | "bottom", @@ -466,6 +474,8 @@ export function createVimController( } } + if (hasModifier) return; + if (pending) { const command = pending; pending = null; @@ -489,6 +499,7 @@ export function createVimController( const line = currentPosition()?.lineNumber ?? null; if (line !== null) { if (lowerKey === "t") codeEditor.revealLineNearTop(line); + else if (lowerKey === "b") revealLineNearBottom(codeEditor, line); else codeEditor.revealLineInCenter(line); } consume(event); From 1d4e480e6b528719baa2eb2dfe94b34d322f8f80 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 14:32:27 +0100 Subject: [PATCH 12/14] fix(web): address remaining Vim review findings --- .../src/support/MonacoEditor.tsx | 3 +- .../src/support/vimMode.test.ts | 27 ++++++ .../resource-renderers/src/support/vimMode.ts | 88 +++++++++++++------ 3 files changed, 90 insertions(+), 28 deletions(-) diff --git a/packages/resource-renderers/src/support/MonacoEditor.tsx b/packages/resource-renderers/src/support/MonacoEditor.tsx index 54b455ae76..7fc5652e37 100644 --- a/packages/resource-renderers/src/support/MonacoEditor.tsx +++ b/packages/resource-renderers/src/support/MonacoEditor.tsx @@ -2,6 +2,7 @@ import { RiKeyboardLine } from "@remixicon/react"; import { useT } from "@thinkrail/locales/react"; import { Button } from "@thinkrail/ui/button"; import { onFontsChange } from "@thinkrail/ui/fonts"; +import { isImeComposing } from "@thinkrail/ui/keyboard"; import type { editor } from "monaco-editor/editor/editor.api.js"; import { useEffect, @@ -131,7 +132,6 @@ export default function MonacoEditor({ const activeVimToggle = registerActiveVimToggle(vimController.toggle); vimControllerRef.current = vimController; activeVimToggleRef.current = activeVimToggle; - activeVimToggle.activate(); const focusListener = codeEditor.onDidFocusEditorText(activeVimToggle.activate); if (isEditorViewState(mount.viewState)) codeEditor.restoreViewState(mount.viewState); const threads = mount.review ? attachReviewThreads(codeEditor, setReviewZones) : null; @@ -271,6 +271,7 @@ export default function MonacoEditor({ value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} onKeyDown={(event) => { + if (isImeComposing(event.nativeEvent)) return; if (event.key === "Enter") { event.preventDefault(); submitSearch(); diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 2db1550688..2bb3bd54d1 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -25,6 +25,7 @@ function makeEditor(lines: readonly string[]) { let selection: IRange | null = null; let scrollTop = 0; let listener: KeyListener | null = null; + let contentListener: (() => void) | null = null; const text = lines.join("\n"); const offsets: number[] = []; for (const index of lines.keys()) { @@ -76,12 +77,32 @@ function makeEditor(lines: readonly string[]) { } return matches; }, + findNextMatch: (query: string, start: IPosition) => + model + .findMatches(query) + .find( + (candidate) => + candidate.range.startLineNumber * 1_000_000 + candidate.range.startColumn > + start.lineNumber * 1_000_000 + start.column, + ), + findPreviousMatch: (query: string, start: IPosition) => + model + .findMatches(query) + .findLast( + (candidate) => + candidate.range.startLineNumber * 1_000_000 + candidate.range.startColumn < + start.lineNumber * 1_000_000 + start.column, + ), }; const editor = { onKeyDown: (next: KeyListener) => { listener = next; return { dispose: () => (listener = null) }; }, + onDidChangeModelContent: (next: () => void) => { + contentListener = next; + return { dispose: () => (contentListener = null) }; + }, getModel: () => model, getPosition: () => current, getSelection: () => { @@ -151,6 +172,9 @@ function makeEditor(lines: readonly string[]) { } as unknown as KeyboardEvent; listener?.({ browserEvent } as unknown as IKeyboardEvent); }, + modelChanged() { + contentListener?.(); + }, }; } @@ -252,6 +276,9 @@ describe("Vim mode", () => { fake.emit("v"); fake.emit("V", true); expect(state.visualMode).toBe("line"); + fake.modelChanged(); + expect(state.visualMode).toBeNull(); + expect(state.mode).toBe("normal"); fake.emit("Escape"); controller.dispose(); }); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index 47f501057e..624ce67642 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -59,10 +59,6 @@ export function toggleActiveVimMode(): void { activeToggle?.(); } -function positionKey(position: IPosition): number { - return position.lineNumber * 1_000_000 + position.column; -} - function isWordCharacter(value: string | undefined): boolean { return value !== undefined && /[A-Za-z0-9_]/u.test(value); } @@ -86,9 +82,14 @@ function moveWordBackward(model: editor.ITextModel, position: IPosition): IPosit function moveWordEnd(model: editor.ITextModel, position: IPosition): IPosition { const text = model.getValue(); let offset = model.getOffsetAt(position); + if (isWordCharacter(text[offset]) && isWordCharacter(text[offset + 1])) { + while (offset < text.length && isWordCharacter(text[offset + 1])) offset += 1; + return model.getPositionAt(offset); + } + offset += 1; while (offset < text.length && !isWordCharacter(text[offset])) offset += 1; while (offset < text.length && isWordCharacter(text[offset + 1])) offset += 1; - return model.getPositionAt(offset); + return model.getPositionAt(Math.min(offset, text.length)); } function movePosition( @@ -246,6 +247,7 @@ export function createVimController( let state = INITIAL_STATE; let count = ""; let anchor: IPosition | null = null; + let visualPosition: IPosition | null = null; let pending: PendingCommand | null = null; let lastSearch: { query: string; direction: VimSearchDirection; wholeWord: boolean } | null = null; @@ -259,6 +261,8 @@ export function createVimController( }; const currentPosition = () => codeEditor.getPosition(); + const motionPosition = () => + state.mode === "visual" ? (visualPosition ?? currentPosition()) : currentPosition(); const recordJump = (target: IPosition) => { const current = currentPosition(); @@ -285,6 +289,7 @@ export function createVimController( const setVisualPosition = (position: IPosition) => { if (!anchor) anchor = currentPosition() ?? position; + visualPosition = position; if (state.visualMode === "line") { const model = codeEditor.getModel(); const end = Math.max(anchor.lineNumber, position.lineNumber); @@ -336,36 +341,42 @@ export function createVimController( direction: VimSearchDirection, repetitions = 1, wholeWord = false, + saveSearch = true, ) => { const model = codeEditor.getModel(); const current = currentPosition(); if (!model || !current || query.length === 0) return; const selection = codeEditor.getSelection(); - let searchPosition = + let searchPosition: IPosition = direction === "forward" ? (selection?.getEndPosition() ?? current) : (selection?.getStartPosition() ?? current); const searchQuery = wholeWord ? `\\b${escapeRegex(query)}\\b` : query; - const matches = model.findMatches(searchQuery, false, wholeWord, false, null, false); - if (matches.length === 0) return; - recordJump(matches[0]?.range.getStartPosition() ?? current); + let match: ReturnType | null = null; for (let index = 0; index < repetitions; index += 1) { - const currentOffset = positionKey(searchPosition); - const match = + match = direction === "forward" - ? (matches.find( - (candidate) => positionKey(candidate.range.getStartPosition()) > currentOffset, - ) ?? matches[0]) - : (matches.findLast( - (candidate) => positionKey(candidate.range.getStartPosition()) < currentOffset, - ) ?? matches[matches.length - 1]); + ? model.findNextMatch(searchQuery, searchPosition, wholeWord, false, null, false) + : model.findPreviousMatch(searchQuery, searchPosition, wholeWord, false, null, false); + if (!match) { + searchPosition = + direction === "forward" + ? { lineNumber: 1, column: 1 } + : model.getPositionAt(model.getValueLength()); + match = + direction === "forward" + ? model.findNextMatch(searchQuery, searchPosition, wholeWord, false, null, false) + : model.findPreviousMatch(searchQuery, searchPosition, wholeWord, false, null, false); + } if (!match) return; searchPosition = direction === "forward" ? match.range.getEndPosition() : match.range.getStartPosition(); - codeEditor.setSelection(match.range); - codeEditor.revealRangeInCenter(match.range); } - lastSearch = { query, direction, wholeWord }; + if (!match) return; + recordJump(match.range.getStartPosition()); + codeEditor.setSelection(match.range); + codeEditor.revealRangeInCenter(match.range); + if (saveSearch) lastSearch = { query, direction, wholeWord }; }; const jumpHistoryBy = (delta: -1 | 1) => { @@ -392,6 +403,7 @@ export function createVimController( const enabled = !state.enabled; count = ""; anchor = null; + visualPosition = null; pending = null; update({ enabled, mode: "normal", visualMode: null, searchOpen: false }); codeEditor.focus(); @@ -407,6 +419,7 @@ export function createVimController( if (state.mode === "insert") { if (key !== "Escape") return; consume(event); + visualPosition = null; update({ mode: "normal", visualMode: null }); return; } @@ -415,6 +428,7 @@ export function createVimController( consume(event); count = ""; anchor = null; + visualPosition = null; pending = null; update({ mode: "normal", visualMode: null }); return; @@ -434,7 +448,9 @@ export function createVimController( if (lowerKey === "v") { consume(event); anchor = currentPosition(); + visualPosition = anchor; update({ mode: "visual", visualMode: "block" }); + if (visualPosition) setVisualPosition(visualPosition); return; } if (lowerKey === "o" || lowerKey === "i") { @@ -443,7 +459,7 @@ export function createVimController( return; } const visible = codeEditor.getVisibleRanges()[0]; - const current = currentPosition(); + const current = motionPosition(); if (visible && current) { const page = Math.max(1, visible.endLineNumber - visible.startLineNumber); const delta = @@ -481,7 +497,7 @@ export function createVimController( pending = null; if (command.kind === "character" && key.length === 1) { const model = codeEditor.getModel(); - const position = currentPosition(); + const position = motionPosition(); if (model && position) applyPosition( findCharacter(model, position, key, command.direction, command.before, command.count), @@ -491,7 +507,7 @@ export function createVimController( } if (command.kind === "g" && lowerKey === "g") { const model = codeEditor.getModel(); - if (model) applyPosition(lineStart(model, command.count, false)); + if (model) applyPosition(lineStart(model, command.count, false), true); consume(event); return; } @@ -555,15 +571,18 @@ export function createVimController( consume(event); count = ""; anchor = currentPosition(); + visualPosition = anchor; update({ mode: "visual", visualMode: key === "V" ? "line" : "character" }); + if (visualPosition) setVisualPosition(visualPosition); return; } if (state.mode === "visual" && (key === "o" || key === "O")) { - const position = currentPosition(); + const position = motionPosition(); if (position && anchor) { const previousAnchor = anchor; anchor = position; + visualPosition = previousAnchor; setVisualPosition(previousAnchor); } consume(event); @@ -583,6 +602,7 @@ export function createVimController( : lastSearch.direction, repetitions, lastSearch.wholeWord, + false, ); return; } @@ -654,7 +674,7 @@ export function createVimController( if (key === "^" || key === "0" || key === "$") { consume(event); - const position = currentPosition(); + const position = motionPosition(); const model = codeEditor.getModel(); if (position && model) { const next = @@ -698,7 +718,7 @@ export function createVimController( } const model = codeEditor.getModel(); - const position = currentPosition(); + const position = motionPosition(); if (!model || !position) return; const next = movePosition(model, position, lowerKey, clampCount(count)); if (!next) return; @@ -708,11 +728,25 @@ export function createVimController( }; const listener = codeEditor.onKeyDown(onKeyDown); + const modelContentListener = codeEditor.onDidChangeModelContent(() => { + count = ""; + anchor = null; + visualPosition = null; + pending = null; + lastSearch = null; + marks.clear(); + jumpHistory = []; + jumpIndex = -1; + update({ mode: "normal", visualMode: null, searchOpen: false }); + }); return { toggle, submitSearch, cancelSearch, - dispose: () => listener.dispose(), + dispose: () => { + listener.dispose(); + modelContentListener.dispose(); + }, }; } From 520ef128dee66feed251b542e2f415e9698af7b3 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 14:39:23 +0100 Subject: [PATCH 13/14] fix(web): preserve Vim selection semantics --- .../src/support/vimMode.test.ts | 1 + .../resource-renderers/src/support/vimMode.ts | 25 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/resource-renderers/src/support/vimMode.test.ts b/packages/resource-renderers/src/support/vimMode.test.ts index 2bb3bd54d1..cdc1012668 100644 --- a/packages/resource-renderers/src/support/vimMode.test.ts +++ b/packages/resource-renderers/src/support/vimMode.test.ts @@ -272,6 +272,7 @@ describe("Vim mode", () => { fake.emit("Escape"); fake.emit("v"); expect(state.visualMode).toBe("character"); + expect(fake.selection).not.toBeNull(); fake.emit("Escape"); fake.emit("v"); fake.emit("V", true); diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index 624ce67642..fa0a4e9672 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -59,6 +59,10 @@ export function toggleActiveVimMode(): void { activeToggle?.(); } +function positionKey(position: IPosition): number { + return position.lineNumber * 1_000_000 + position.column; +} + function isWordCharacter(value: string | undefined): boolean { return value !== undefined && /[A-Za-z0-9_]/u.test(value); } @@ -305,28 +309,39 @@ export function createVimController( return; } if (state.visualMode === "block") { + const model = codeEditor.getModel(); const startLine = Math.min(anchor.lineNumber, position.lineNumber); const endLine = Math.max(anchor.lineNumber, position.lineNumber); const startColumn = Math.min(anchor.column, position.column); const endColumn = Math.max(anchor.column, position.column); + const endColumnExclusive = model + ? Math.min(model.getLineMaxColumn(endLine), endColumn + 1) + : endColumn + 1; const selections: ISelection[] = []; for (let line = startLine; line <= endLine; line += 1) { selections.push({ selectionStartLineNumber: line, selectionStartColumn: startColumn, positionLineNumber: line, - positionColumn: endColumn, + positionColumn: endColumnExclusive, }); } codeEditor.setSelections(selections); codeEditor.revealPositionInCenter(position); return; } + const model = codeEditor.getModel(); + const anchorIsBefore = positionKey(anchor) <= positionKey(position); + const start = anchorIsBefore ? anchor : position; + const end = anchorIsBefore ? position : anchor; + const endColumnExclusive = model + ? Math.min(model.getLineMaxColumn(end.lineNumber), end.column + 1) + : end.column + 1; codeEditor.setSelection({ - selectionStartLineNumber: anchor.lineNumber, - selectionStartColumn: anchor.column, - positionLineNumber: position.lineNumber, - positionColumn: position.column, + selectionStartLineNumber: start.lineNumber, + selectionStartColumn: start.column, + positionLineNumber: end.lineNumber, + positionColumn: endColumnExclusive, }); codeEditor.revealPositionInCenter(position); }; From f69bae451919100c7a043888a0dae1825c0ff0f8 Mon Sep 17 00:00:00 2001 From: "Mariusz Barycz (workstation)" Date: Sat, 14 Nov 2026 14:41:11 +0100 Subject: [PATCH 14/14] fix(web): preserve Visual Vim motions --- .../resource-renderers/src/support/vimMode.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/resource-renderers/src/support/vimMode.ts b/packages/resource-renderers/src/support/vimMode.ts index fa0a4e9672..5422885a26 100644 --- a/packages/resource-renderers/src/support/vimMode.ts +++ b/packages/resource-renderers/src/support/vimMode.ts @@ -400,7 +400,7 @@ export function createVimController( if (nextIndex === jumpIndex) return; jumpIndex = nextIndex; const position = jumpHistory[jumpIndex]; - if (position) setPosition(position); + if (position) applyPosition(position); }; const submitSearch = (query: string) => { @@ -462,6 +462,7 @@ export function createVimController( ) { if (lowerKey === "v") { consume(event); + count = ""; anchor = currentPosition(); visualPosition = anchor; update({ mode: "visual", visualMode: "block" }); @@ -470,15 +471,18 @@ export function createVimController( } if (lowerKey === "o" || lowerKey === "i") { consume(event); + count = ""; jumpHistoryBy(lowerKey === "o" ? -1 : 1); return; } + const ctrlCount = clampCount(count); + count = ""; const visible = codeEditor.getVisibleRanges()[0]; const current = motionPosition(); if (visible && current) { const page = Math.max(1, visible.endLineNumber - visible.startLineNumber); const delta = - lowerKey === "f" + (lowerKey === "f" ? page : lowerKey === "b" ? -page @@ -486,23 +490,24 @@ export function createVimController( ? Math.floor(page / 2) : lowerKey === "u" ? -Math.floor(page / 2) - : 0; + : 0) * ctrlCount; + consume(event); if (delta !== 0) { - consume(event); const model = codeEditor.getModel(); if (model) { const lineNumber = Math.max( 1, Math.min(model.getLineCount(), current.lineNumber + delta), ); - setPosition({ + applyPosition({ lineNumber, column: Math.min(model.getLineMaxColumn(lineNumber), current.column), }); } - return; } + return; } + consume(event); } if (hasModifier) return; @@ -546,7 +551,7 @@ export function createVimController( const mark = marks.get(key); if (mark) { const model = codeEditor.getModel(); - setPosition( + applyPosition( command.exact ? mark : model ? lineStart(model, mark.lineNumber, false) : mark, true, );