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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,9 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
split tab is not offered it. The view follows the pane there, in whichever half of a split shows
it, and this browser's unsent draft for it goes along (herdr gives the pane a new ID). The
palette has the same move as one Move tab to {workspace} row per workspace, found by a query
only, as Settings pages are.
only, as Settings pages are. Rename tab is also a palette action and a rebindable shortcut with
no default key, as herdr's own `rename_tab` binding: it opens the same name field on the selected
pane's tab, and is not offered where the strip is left out (a lone tab on a cramped screen).
The `⋯` beside the selected tab holds the same tab actions (the strip's `+` opens a shell tab, so
New tab with agent… stays here) and also carries the open pane's actions, less Split and Zoom where a phone shows one pane at a time.
Touch and narrow layouts keep that `⋯` in view; a desktop with a mouse (`hover: hover`,
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/rename-tab-shortcut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added
- **Rename tab** is a command palette action and a shortcut with no default key (bind one in
Settings → Shortcuts): it opens the inline name field of the open tab, as the tab menu's Rename
does.
4 changes: 2 additions & 2 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,8 +596,8 @@ focus where it was. **Mod+Shift+E** focuses the selected pane's input in any mod
terminal's input line and direct input; it prefers masked input while a secret prompt is open. The
shortcut can be reassigned or disabled in Settings → Shortcuts.

**New tab**, **Split pane right** and **Split pane down** have no default key: pick one in
**Settings → Shortcuts**, and the command palette shows it beside the action.
**New tab**, **Rename tab**, **Split pane right** and **Split pane down** have no default key:
pick one in **Settings → Shortcuts**, and the command palette shows it beside the action.

**Settings → Shortcuts** changes each app binding or turns it off: press **Record** on a row,
then the new chord (Escape cancels). A Mod+Shift chord is always safe for the terminal. Other
Expand Down
2 changes: 1 addition & 1 deletion scripts/session-history-fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function Root() {
const join = (panes: HerdrPane[]) => setSnapshot((current) => ({ ...current, panes: [...current.panes, ...panes] }));
window.qa.rosterCatchUp = () => join(held.current.splice(0));
const actions: AppActions = {
selectPane: setSelected, selectAdjacentPane: noop, setView: noop, toggleView: noop, focusInput: noop, scrollPage: noop, openNewSession: noop, openNewTab: noop,
selectPane: setSelected, selectAdjacentPane: noop, setView: noop, toggleView: noop, focusInput: noop, scrollPage: noop, openNewSession: noop, openNewTab: noop, renameTab: noop,
openTerminalTab: async () => {}, openSessionHistory: (target) => {
// as App does: prefer Claude for its store, keeping the original request for picker changes
if (target) setHistory({ workspaceId: target.workspaceId, agent: target.agent ?? null, askingPaneId: target.paneId ?? null, paneId: sessionHistorySourcePane(sessionHistoryAgent(target.agent) ?? "codex", snapshot.panes, { workspaceId: target.workspaceId, askingPaneId: target.paneId, selectedPaneId: selected }) });
Expand Down
42 changes: 42 additions & 0 deletions scripts/shortcut-exact-modifiers-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,48 @@ try {
await page.locator(".settings-dialog").waitFor();
await page.getByRole("button", { name: "Close settings", exact: true }).click();

// Rename tab has no default key: a recorded chord opens the open tab's name field, Escape cancels, Enter renames
await hungarianE();
await openSettingsPage(page, "Shortcuts");
const recorded = await record(page, "Rename tab", "Meta+Shift+KeyY");
assert.equal((await stored(page))["rename-tab"], "y", recorded);
await page.getByRole("button", { name: "Close settings", exact: true }).click();
const tabName = page.locator(".tab-strip-item.is-active .tab-strip-rename");
const tabLabel = (await page.locator('.tab-strip-item.is-active [role="tab"]').textContent()) ?? "";
await page.keyboard.press("Meta+Shift+KeyY");
await tabName.waitFor();
assert.equal(await tabName.evaluate((input) => input === document.activeElement), true, "the name field has the focus");
if (screenshotDir) await page.screenshot({ path: join(screenshotDir, "rename-tab-shortcut.png") });
await page.keyboard.press("Escape");
await tabName.waitFor({ state: "detached" });
assert.equal(await page.locator('.tab-strip-item.is-active [role="tab"]').textContent(), tabLabel, "Escape keeps the name");
// the request is one-shot: leaving the pane and coming back does not open the field again
const openTab = () => document.querySelector<HTMLElement>('.tab-strip-item.is-active [role="tab"]')?.dataset["tabId"] ?? null;
const firstTab = await page.evaluate(openTab);
await page.keyboard.press("Meta+Shift+End");
await page.waitForFunction((before) => (document.querySelector<HTMLElement>('.tab-strip-item.is-active [role="tab"]')?.dataset["tabId"] ?? null) !== before, firstTab);
await page.keyboard.press("Meta+Shift+ArrowUp");
await page.waitForFunction((before) => (document.querySelector<HTMLElement>('.tab-strip-item.is-active [role="tab"]')?.dataset["tabId"] ?? null) === before, firstTab);
assert.equal(await page.locator(".tab-strip-rename").count(), 0, "coming back to the pane does not reopen the name field");
await page.keyboard.press("Meta+Shift+KeyY");
await tabName.waitFor();
await page.keyboard.type("Renamed by shortcut");
// a second press while the field is open keeps what was typed
await page.keyboard.press("Meta+Shift+KeyY");
await page.keyboard.press("Enter");
await tabName.waitFor({ state: "detached" });
assert.match((await page.locator('.tab-strip-item.is-active [role="tab"]').textContent()) ?? "", /Renamed by shortcut/, "Enter renames the tab");
// the demo's authoritative snapshot carries the name, so herdr was sent the rename
await page.waitForFunction(async () => (await (await fetch("/api/session")).json()).snapshot.tabs.some((tab: { label: string }) => tab.label === "Renamed by shortcut"));
// the palette offers the same action
await page.keyboard.press("Control+KeyK");
await palette.waitFor();
await page.keyboard.type("rename tab");
await page.getByRole("option", { name: /Rename tab/ }).first().click();
await tabName.waitFor();
assert.equal(await tabName.evaluate((input) => input === document.activeElement), true, "the palette action focuses the name field");
await page.keyboard.press("Escape");

// a binding on End gives way to a message box that has text, as the arrows do; an empty box lets it run
const composer = page.locator(".composer-text").first();
await composer.fill("draft");
Expand Down
14 changes: 13 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { useMachinesRef, useRecentFolderMemory, useRefetchTimer, useRosterError,
import { useMachineDialog } from "./lib/useMachineDialog.ts";
import { useNewSessionMachine, useNewSessionState, useSessionHistoryList } from "./lib/useNewSessionDialog.ts";
import { useCommandPalette } from "./lib/useCommandPalette.ts";
import { useRenameRequest } from "./lib/useRenameRequest.ts";
import { useFilePanels } from "./lib/useFilePanels.ts";
import { usePaneCoverage } from "./lib/usePaneCoverage.ts";
import { useT } from "./lib/i18n.ts";
Expand Down Expand Up @@ -187,6 +188,8 @@ export function App() {
const { paletteOpen, paletteQuery, openPalette, setPaletteOpen } = useCommandPalette();
const paneControls = useRef<PaneControls | null>(null);
const [findRequest, setFindRequest] = useState<{ paneId: string | null; serial: number }>({ paneId: null, serial: 0 });
// the Rename tab shortcut and palette action: the tab strip of the selected pane opens its name field
const { renameRequest, requestRename, clearRenameRequest } = useRenameRequest(selectedMachineId, selectedPaneId);
// the header's More menu: its button
const [more, setMore] = useState<{ anchor: HTMLElement } | null>(null);
const closeMore = useCallback(() => setMore(null), []);
Expand Down Expand Up @@ -289,6 +292,8 @@ export function App() {
const { selectedPane } = useSelectedPane({ snapshot, selectedPaneId });
useScreenWakeLock(settings.keepScreenOn && locked === false && selectedPane !== null);
const { selectedWorkspace, presentedPaneId } = useSelectedLayout({ selectedPane, snapshot, drawerOpen, selectedMachineId, selectedPaneId, setSelectedPaneId, setAutoSelected });
// the selected pane has a tab strip to rename in: its pane and workspace are in the roster
const tabStripMounted = selectedPane !== null && selectedWorkspace !== null;
const targetHerdr = selectedMachineId === "local" ? health?.herdr : selectedMachine?.herdr;
// the herdr the header names as current: this PC's last answer only while it is still shown (through one
// failed read, not once unavailable), though its capabilities stay in targetHerdr
Expand Down Expand Up @@ -385,6 +390,11 @@ export function App() {
setNewTab({ workspaceId: workspace.workspace_id, workspaceLabel: workspace.label, cwd, number: workspace.tab_count + 1 });
setNewSessionOpen(true);
},
renameTab: () => {
if (selectedPaneId === null || !tabStripMounted) return;
setDrawerOpen(false);
requestRename(selectedMachineId, selectedPaneId);
},
openTerminalTab: async (target) => {
const found = newTabTarget(target);
if (!found) return;
Expand Down Expand Up @@ -485,7 +495,7 @@ export function App() {
void zoomPane(paneId, mode, machineId).then(() => void load()).catch((err) => layoutRefused("pane zoom failed", err));
} : null,
}),
[selectPane, selectedPaneId, selectedMachineId, setView, view, terminalPeek, endPeek, updateSettings, resolvedTheme, canSignOut, lock, bellVisible, bell.run, enableNotifications, load, layoutRefused, moveRefused, lenses],
[selectPane, selectedPaneId, selectedMachineId, tabStripMounted, setView, view, terminalPeek, endPeek, updateSettings, resolvedTheme, canSignOut, lock, bellVisible, bell.run, enableNotifications, load, layoutRefused, moveRefused, lenses],
);

useShortcuts(actions, locked === false);
Expand Down Expand Up @@ -790,6 +800,8 @@ export function App() {
{!(slotSnapshot && pane && workspace) && slotBar}
{slotSnapshot && pane && workspace && (
<TabStrip snapshot={slotSnapshot} workspace={workspace} selectedPane={pane} panelId={slotPanelId} splitDrag={wideScreen} onSelectPane={onSelect} end={slotEnd}
renameRequest={active && renameRequest?.machineId === machineId && renameRequest.paneId === pane.pane_id ? renameRequest : null}
onRenameHandled={clearRenameRequest}
onNewTab={() => actions.openNewTab(active ? undefined : { machineId, workspaceId: workspace.workspace_id })}
onNewTerminal={() => actions.openTerminalTab(active ? undefined : { machineId, workspaceId: workspace.workspace_id })}
onMoveToWorkspace={(tabId, workspaceId) => actions.moveTabToWorkspace({ machineId, tabId, workspaceId })}
Expand Down
10 changes: 8 additions & 2 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MachineContext, useMachineApi, useMachineId } from "../lib/machineContext.tsx";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentType, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { Bell, Columns2, CornerLeftUp, Folder, FolderInput, FolderOpen, FolderSearch, History, LoaderCircle, Maximize2, MessageSquarePlus, Minimize2, Monitor, PanelLeft, Pin, Plus, Puzzle, RefreshCw, Rows2, Settings, SunMoon, SwitchCamera, X } from "lucide-react";
import { Bell, Columns2, CornerLeftUp, Folder, FolderInput, FolderOpen, FolderSearch, History, LoaderCircle, Maximize2, MessageSquarePlus, Minimize2, Monitor, PanelLeft, Pencil, Pin, Plus, Puzzle, RefreshCw, Rows2, Settings, SunMoon, SwitchCamera, X } from "lucide-react";

import "./CommandPalette.css";

Expand All @@ -25,6 +25,7 @@ import { useT } from "../lib/i18n.ts";
import { useFocusTrap } from "../lib/useFocusTrap.ts";
import { useSheetSwipe } from "../lib/sheets.ts";
import { usePhoneSheetBack } from "../lib/sheetHistory.ts";
import { CRAMPED_QUERY, loneTabHidden } from "../lib/paneCanvas.ts";
import { useMediaQuery } from "../lib/useMediaQuery.ts";
import { useWorktreeBranches } from "../lib/useWorktreeBranches.ts";
import { worktreeLabel } from "../lib/worktreeName.ts";
Expand Down Expand Up @@ -153,6 +154,7 @@ export function CommandPalette({ open, initialQuery = "", onClose, machines, onS
const machineId = useMachineId();
const api = useMachineApi();
const touch = useMediaQuery("(pointer: coarse)");
const cramped = useMediaQuery(CRAMPED_QUERY);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<PaletteStatusFilter>("all");
const [selection, setSelection] = useState<PaletteSelection>(NO_SELECTION);
Expand Down Expand Up @@ -297,11 +299,15 @@ export function CommandPalette({ open, initialQuery = "", onClose, machines, onS
// browsing starts in the selected pane's folder, home without one
const selectedPane = snapshot?.panes.find((pane) => pane.pane_id === selectedPaneId);
const browseStart = selectedPane && panePath(selectedPane) ? panePath(selectedPane) : null;
// Rename tab opens the tab strip's name field: not offered where the strip is left out (a lone tab on a cramped screen)
const tabCount = selectedPane ? snapshot?.tabs.filter((tab) => tab.workspace_id === selectedPane.workspace_id).length ?? 0 : 0;
const stripDrawn = selectedPane !== undefined && !loneTabHidden(cramped, tabCount);

const paletteActions = useMemo<PaletteAction[]>(() => [
{ id: "new", label: t("New workspace"), icon: MessageSquarePlus, shortcut: "new-session", run: actions.openNewSession },
// in the selected pane's workspace: nothing to add a tab to without one
...(selectedPaneId !== null ? [{ id: "new-tab", label: t("New tab"), icon: Plus, shortcut: "new-tab", run: () => actions.openNewTab() }] satisfies PaletteAction[] : []),
...(selectedPaneId !== null && stripDrawn ? [{ id: "rename-tab", label: t("Rename tab"), icon: Pencil, shortcut: "rename-tab", run: actions.renameTab }] satisfies PaletteAction[] : []),
// herdr's prefix+v and prefix+-: the new pane is opened here only when asked, as herdr's --focus
...(splitPane ? [
{ id: "split-right", label: t("Split pane right"), icon: Columns2, shortcut: "split-right", run: () => splitPane("right") },
Expand All @@ -319,7 +325,7 @@ export function CommandPalette({ open, initialQuery = "", onClose, machines, onS
...(actions.openFiles ? [{ id: "files", label: t("Browse files"), icon: FolderOpen, run: actions.openFiles }] : []),
...(online ? [{ id: "browse-folders", label: t("Browse folders"), icon: FolderSearch, keepOpen: true, run: () => browseTo(browseStart ?? home ?? "~") }] : []),
{ id: "refresh", label: t("Refresh"), icon: RefreshCw, run: actions.refresh },
], [actions, splitPane, zoomPane, tabPanes, zoom, view, t, selectedPaneId, online, browseTo, browseStart, home]);
], [actions, splitPane, zoomPane, tabPanes, zoom, view, t, selectedPaneId, stripDrawn, online, browseTo, browseStart, home]);

// Settings pages and groups are found by a query only, and open on the page itself, a group's
// scrolled to and focused: the dialog reads the request as it mounts
Expand Down
Loading
Loading