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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ 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 carries an accent line, a frame briefly lights the pane the keyboard takes you to, 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. `⌘?` opens a filterable cheatsheet of every shortcut, the ones for the pane you're in first (a Mac browser keeps `⌘?` for its Help menu, so use the keyboard icon at the bottom of the left tool rail or the palette there); tooltips show a button's shortcut, and Settings → Keyboard lists them all too.

- **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.

- **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.
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/shell/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/shell/paletteCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ function deps(overrides: Partial<PaletteCatalogDeps> = {}): PaletteCatalogDeps {
toggleLeft: noop,
toggleRight: noop,
toggleBottom: noop,
toggleVim: noop,
showTool: noop,
showFiles: noop,
focusRegion: noop,
Expand Down Expand Up @@ -141,6 +142,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({
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/shell/paletteCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,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;
Expand Down Expand Up @@ -375,6 +376,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"),
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/shell/useSearchPalette.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions e2e/editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion goal-and-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 carries an accent line, a keyboard move briefly frames the region it lands in, 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]]).
one keybinding table that the 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]]).
Expand Down
Loading