mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
feat(agent-manager): add keyboard shortcut tooltips and worktree hover cards (#516)
* feat(agent-manager): add keyboard shortcut tooltips and worktree hover cards - Add TooltipKeybind to all agent manager buttons (tabs, new session, close tab, terminal, new worktree, delete worktree, promote to worktree) - Add HoverCard popovers on worktree items showing branch name, base branch, session count, creation date, and navigation shortcut hint - Show directional keybind hints only for directly adjacent items (tabs: ⌘←/⌘→, worktrees: ⌘↑/⌘↓) - Resolve keybindings from package.json at runtime per platform (Mac symbols vs Windows/Linux Ctrl+key format) - Send resolved keybindings from extension to webview via new agentManager.keybindings message instead of hardcoding shortcuts - Prevent multiple HoverCards from appearing simultaneously via shared hover state lifted above the For loop - Suppress HoverCard when hovering the delete button * refactor: extract formatKeybinding and adjacentHint into testable modules - Extract formatKeybinding into format-keybinding.ts (pure function, no vscode dependency, takes mac boolean parameter) - Extract adjacentHint into navigate.ts as a reusable pure function for computing directional keybind hints - Add tests for adjacentHint (12 cases) and formatKeybinding (13 cases) - Simplify tab/worktree direction logic in AgentManagerApp to use adjacentHint instead of inline index arithmetic
This commit is contained in:
@@ -7,6 +7,7 @@ import { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import { SetupScriptService } from "./SetupScriptService"
|
||||
import { SetupScriptRunner } from "./SetupScriptRunner"
|
||||
import { SessionTerminalManager } from "./SessionTerminalManager"
|
||||
import { formatKeybinding } from "./format-keybinding"
|
||||
|
||||
/**
|
||||
* AgentManagerProvider opens the Agent Manager panel.
|
||||
@@ -75,6 +76,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
|
||||
void this.initializeState()
|
||||
void this.sendRepoInfo()
|
||||
this.sendKeybindings()
|
||||
|
||||
this.panel.onDidDispose(() => {
|
||||
this.log("Panel disposed")
|
||||
@@ -393,6 +395,28 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keybindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private sendKeybindings(): void {
|
||||
const ext = vscode.extensions.getExtension("kilocode.kilo-code")
|
||||
const keybindings: Array<{ command: string; key?: string; mac?: string }> =
|
||||
ext?.packageJSON?.contributes?.keybindings ?? []
|
||||
|
||||
const mac = process.platform === "darwin"
|
||||
const prefix = "kilo-code.new.agentManager."
|
||||
const bindings: Record<string, string> = {}
|
||||
for (const kb of keybindings) {
|
||||
if (!kb.command.startsWith(prefix)) continue
|
||||
const action = kb.command.slice(prefix.length)
|
||||
const raw = mac ? (kb.mac ?? kb.key) : kb.key
|
||||
if (raw) bindings[action] = formatKeybinding(raw, mac)
|
||||
}
|
||||
|
||||
this.postToWebview({ type: "agentManager.keybindings", bindings })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup script
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const KEY_SYMBOLS: Record<string, { mac: string; other: string }> = {
|
||||
ctrl: { mac: "⌃", other: "Ctrl" },
|
||||
cmd: { mac: "⌘", other: "Ctrl" },
|
||||
shift: { mac: "⇧", other: "Shift" },
|
||||
alt: { mac: "⌥", other: "Alt" },
|
||||
}
|
||||
|
||||
const SPECIAL_KEYS: Record<string, string> = {
|
||||
left: "←",
|
||||
right: "→",
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
backspace: "⌫",
|
||||
delete: "Del",
|
||||
enter: "↵",
|
||||
escape: "Esc",
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a VS Code keybinding string (e.g. "cmd+shift+w") into
|
||||
* a display string using platform-appropriate symbols.
|
||||
* Mac: "⌘⇧W" Windows/Linux: "Ctrl+Shift+W"
|
||||
*/
|
||||
export function formatKeybinding(raw: string, mac: boolean): string {
|
||||
const symbols = raw
|
||||
.split("+")
|
||||
.map((p) => p.trim().toLowerCase())
|
||||
.map((part) => {
|
||||
const mod = KEY_SYMBOLS[part]
|
||||
if (mod) return mac ? mod.mac : mod.other
|
||||
return SPECIAL_KEYS[part] ?? part.toUpperCase()
|
||||
})
|
||||
return mac ? symbols.join("") : symbols.join("+")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { formatKeybinding } from "../../src/agent-manager/format-keybinding"
|
||||
|
||||
describe("formatKeybinding", () => {
|
||||
describe("mac", () => {
|
||||
it("formats cmd as ⌘", () => {
|
||||
expect(formatKeybinding("cmd+w", true)).toBe("⌘W")
|
||||
})
|
||||
|
||||
it("formats cmd+shift as ⌘⇧", () => {
|
||||
expect(formatKeybinding("cmd+shift+w", true)).toBe("⌘⇧W")
|
||||
})
|
||||
|
||||
it("formats ctrl as ⌃", () => {
|
||||
expect(formatKeybinding("ctrl+c", true)).toBe("⌃C")
|
||||
})
|
||||
|
||||
it("formats alt as ⌥", () => {
|
||||
expect(formatKeybinding("alt+f", true)).toBe("⌥F")
|
||||
})
|
||||
|
||||
it("formats arrow keys as symbols", () => {
|
||||
expect(formatKeybinding("cmd+left", true)).toBe("⌘←")
|
||||
expect(formatKeybinding("cmd+right", true)).toBe("⌘→")
|
||||
expect(formatKeybinding("cmd+up", true)).toBe("⌘↑")
|
||||
expect(formatKeybinding("cmd+down", true)).toBe("⌘↓")
|
||||
})
|
||||
|
||||
it("formats special keys", () => {
|
||||
expect(formatKeybinding("cmd+backspace", true)).toBe("⌘⌫")
|
||||
expect(formatKeybinding("cmd+enter", true)).toBe("⌘↵")
|
||||
expect(formatKeybinding("escape", true)).toBe("Esc")
|
||||
})
|
||||
|
||||
it("joins without separator on mac", () => {
|
||||
expect(formatKeybinding("cmd+shift+alt+t", true)).toBe("⌘⇧⌥T")
|
||||
})
|
||||
|
||||
it("formats plain key", () => {
|
||||
expect(formatKeybinding("cmd+/", true)).toBe("⌘/")
|
||||
})
|
||||
})
|
||||
|
||||
describe("windows/linux", () => {
|
||||
it("formats cmd as Ctrl", () => {
|
||||
expect(formatKeybinding("cmd+w", false)).toBe("Ctrl+W")
|
||||
})
|
||||
|
||||
it("formats ctrl as Ctrl", () => {
|
||||
expect(formatKeybinding("ctrl+w", false)).toBe("Ctrl+W")
|
||||
})
|
||||
|
||||
it("formats ctrl+shift", () => {
|
||||
expect(formatKeybinding("ctrl+shift+w", false)).toBe("Ctrl+Shift+W")
|
||||
})
|
||||
|
||||
it("formats alt as Alt", () => {
|
||||
expect(formatKeybinding("alt+f", false)).toBe("Alt+F")
|
||||
})
|
||||
|
||||
it("formats arrow keys as symbols", () => {
|
||||
expect(formatKeybinding("ctrl+left", false)).toBe("Ctrl+←")
|
||||
expect(formatKeybinding("ctrl+right", false)).toBe("Ctrl+→")
|
||||
expect(formatKeybinding("ctrl+up", false)).toBe("Ctrl+↑")
|
||||
expect(formatKeybinding("ctrl+down", false)).toBe("Ctrl+↓")
|
||||
})
|
||||
|
||||
it("joins with + separator on non-mac", () => {
|
||||
expect(formatKeybinding("ctrl+shift+alt+t", false)).toBe("Ctrl+Shift+Alt+T")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { resolveNavigation, validateLocalSession, LOCAL } from "../../webview-ui/agent-manager/navigate"
|
||||
import { resolveNavigation, validateLocalSession, adjacentHint, LOCAL } from "../../webview-ui/agent-manager/navigate"
|
||||
|
||||
const ids = ["a", "b", "c", "d"]
|
||||
|
||||
@@ -134,3 +134,49 @@ describe("validateLocalSession", () => {
|
||||
expect(validateLocalSession(undefined, [])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("adjacentHint", () => {
|
||||
const flat = [LOCAL, "wt1", "wt2", "wt3", "s1"]
|
||||
|
||||
it("returns prev hint when item is directly above active", () => {
|
||||
expect(adjacentHint("wt1", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↑")
|
||||
})
|
||||
|
||||
it("returns next hint when item is directly below active", () => {
|
||||
expect(adjacentHint("wt3", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↓")
|
||||
})
|
||||
|
||||
it("returns empty string for the active item itself", () => {
|
||||
expect(adjacentHint("wt2", "wt2", flat, "⌘↑", "⌘↓")).toBe("")
|
||||
})
|
||||
|
||||
it("returns empty string for non-adjacent items", () => {
|
||||
expect(adjacentHint("wt1", "wt3", flat, "⌘↑", "⌘↓")).toBe("")
|
||||
expect(adjacentHint("s1", "wt1", flat, "⌘↑", "⌘↓")).toBe("")
|
||||
})
|
||||
|
||||
it("returns empty string when active is undefined", () => {
|
||||
expect(adjacentHint("wt1", undefined, flat, "⌘↑", "⌘↓")).toBe("")
|
||||
})
|
||||
|
||||
it("returns empty string when active is not in list", () => {
|
||||
expect(adjacentHint("wt1", "unknown", flat, "⌘↑", "⌘↓")).toBe("")
|
||||
})
|
||||
|
||||
it("returns empty string when item is not in list", () => {
|
||||
expect(adjacentHint("unknown", "wt2", flat, "⌘↑", "⌘↓")).toBe("")
|
||||
})
|
||||
|
||||
it("works at boundaries — first item with LOCAL active", () => {
|
||||
expect(adjacentHint("wt1", LOCAL, flat, "⌘↑", "⌘↓")).toBe("⌘↓")
|
||||
})
|
||||
|
||||
it("works at boundaries — LOCAL with first item active", () => {
|
||||
expect(adjacentHint(LOCAL, "wt1", flat, "⌘↑", "⌘↓")).toBe("⌘↑")
|
||||
})
|
||||
|
||||
it("works with single-item list", () => {
|
||||
expect(adjacentHint("a", "b", ["a", "b"], "prev", "next")).toBe("prev")
|
||||
expect(adjacentHint("b", "a", ["a", "b"], "prev", "next")).toBe("next")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AgentManagerRepoInfoMessage,
|
||||
AgentManagerWorktreeSetupMessage,
|
||||
AgentManagerStateMessage,
|
||||
AgentManagerKeybindingsMessage,
|
||||
WorktreeState,
|
||||
ManagedSessionState,
|
||||
SessionInfo,
|
||||
@@ -36,7 +37,8 @@ import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
|
||||
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
|
||||
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
|
||||
import { ServerProvider } from "../src/context/server"
|
||||
@@ -47,7 +49,7 @@ import { WorktreeModeProvider } from "../src/context/worktree-mode"
|
||||
import { ChatView } from "../src/components/chat"
|
||||
import { LanguageBridge, DataBridge } from "../src/App"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
import { validateLocalSession, nextSelectionAfterDelete, LOCAL } from "./navigate"
|
||||
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate"
|
||||
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
|
||||
import { ConstrainDragYAxis, SortableTab } from "./sortable-tab"
|
||||
import "./agent-manager.css"
|
||||
@@ -63,7 +65,19 @@ interface SetupState {
|
||||
type SidebarSelection = typeof LOCAL | string | null
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
const modKey = isMac ? "\u2318" : "Ctrl+"
|
||||
|
||||
// Fallback keybindings before extension sends resolved ones
|
||||
const defaultBindings: Record<string, string> = {
|
||||
previousSession: isMac ? "⌘↑" : "Ctrl+↑",
|
||||
nextSession: isMac ? "⌘↓" : "Ctrl+↓",
|
||||
previousTab: isMac ? "⌘←" : "Ctrl+←",
|
||||
nextTab: isMac ? "⌘→" : "Ctrl+→",
|
||||
showTerminal: isMac ? "⌘/" : "Ctrl+/",
|
||||
newTab: isMac ? "⌘T" : "Ctrl+T",
|
||||
closeTab: isMac ? "⌘W" : "Ctrl+W",
|
||||
newWorktree: isMac ? "⌘N" : "Ctrl+N",
|
||||
closeWorktree: isMac ? "⌘⇧W" : "Ctrl+Shift+W",
|
||||
}
|
||||
|
||||
/** Manages horizontal scroll for the tab list: hides the scrollbar, converts
|
||||
* vertical wheel events to horizontal scroll, tracks overflow to show/hide
|
||||
@@ -135,6 +149,8 @@ const AgentManagerContent: Component = () => {
|
||||
const vscode = useVSCode()
|
||||
const dialog = useDialog()
|
||||
|
||||
const [kb, setKb] = createSignal<Record<string, string>>(defaultBindings)
|
||||
|
||||
const [setup, setSetup] = createSignal<SetupState>({ active: false, message: "" })
|
||||
const [worktrees, setWorktrees] = createSignal<WorktreeState[]>([])
|
||||
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
|
||||
@@ -455,6 +471,11 @@ const AgentManagerContent: Component = () => {
|
||||
session.selectSession(ev.sessionId)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.keybindings") {
|
||||
const ev = msg as AgentManagerKeybindingsMessage
|
||||
setKb(ev.bindings)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.state") {
|
||||
const state = msg as AgentManagerStateMessage
|
||||
setWorktrees(state.worktrees)
|
||||
@@ -754,40 +775,107 @@ const AgentManagerContent: Component = () => {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="New Worktree"
|
||||
onClick={handleCreateWorktree}
|
||||
/>
|
||||
<TooltipKeybind title="New worktree" keybind={kb().newWorktree ?? ""} placement="bottom">
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="New worktree"
|
||||
onClick={handleCreateWorktree}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
<div class="am-worktree-list">
|
||||
<For each={worktrees()}>
|
||||
{(wt) => (
|
||||
<div
|
||||
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
|
||||
data-sidebar-id={wt.id}
|
||||
onClick={() => selectWorktree(wt.id)}
|
||||
>
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-worktree-branch" title={wt.branch}>
|
||||
{worktreeLabel(wt)}
|
||||
</span>
|
||||
<Show when={!deletingWorktrees().has(wt.id)} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Close worktree"
|
||||
class="am-worktree-close"
|
||||
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{(() => {
|
||||
const [hoveredWt, setHoveredWt] = createSignal<string | null>(null)
|
||||
const [overClose, setOverClose] = createSignal(false)
|
||||
return (
|
||||
<For each={worktrees()}>
|
||||
{(wt, wtIdx) => {
|
||||
const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id))
|
||||
const navHint = () => {
|
||||
const flat = [
|
||||
LOCAL as string,
|
||||
...worktrees().map((w) => w.id),
|
||||
...unassignedSessions().map((s) => s.id),
|
||||
]
|
||||
const active = selection() ?? session.currentSessionID() ?? ""
|
||||
return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "")
|
||||
}
|
||||
return (
|
||||
<HoverCard
|
||||
openDelay={100}
|
||||
closeDelay={100}
|
||||
placement="right-start"
|
||||
gutter={8}
|
||||
open={hoveredWt() === wt.id && !overClose()}
|
||||
onOpenChange={(open) => setHoveredWt(open ? wt.id : null)}
|
||||
trigger={
|
||||
<div
|
||||
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
|
||||
data-sidebar-id={wt.id}
|
||||
onClick={() => selectWorktree(wt.id)}
|
||||
>
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-worktree-branch">{worktreeLabel(wt)}</span>
|
||||
<Show
|
||||
when={!deletingWorktrees().has(wt.id)}
|
||||
fallback={<Spinner class="am-worktree-spinner" />}
|
||||
>
|
||||
<div
|
||||
class="am-worktree-close"
|
||||
onMouseEnter={() => setOverClose(true)}
|
||||
onMouseLeave={() => setOverClose(false)}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title="Delete worktree"
|
||||
keybind={kb().closeWorktree ?? ""}
|
||||
placement="top"
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Delete worktree"
|
||||
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="am-hover-card">
|
||||
<div class="am-hover-card-header">
|
||||
<div>
|
||||
<div class="am-hover-card-label">BRANCH</div>
|
||||
<div class="am-hover-card-branch">{wt.branch}</div>
|
||||
<div class="am-hover-card-meta">{formatRelativeDate(wt.createdAt)}</div>
|
||||
</div>
|
||||
<Show when={navHint()}>
|
||||
<span class="am-hover-card-keybind">{navHint()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={wt.parentBranch}>
|
||||
<div class="am-hover-card-divider" />
|
||||
<div class="am-hover-card-row">
|
||||
<span class="am-hover-card-row-label">Base</span>
|
||||
<span class="am-hover-card-row-value">{wt.parentBranch}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="am-hover-card-divider" />
|
||||
<div class="am-hover-card-row">
|
||||
<span class="am-hover-card-row-label">Sessions</span>
|
||||
<span class="am-hover-card-row-value">{sessions().length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
})()}
|
||||
<Show when={worktrees().length === 0}>
|
||||
<button class="am-worktree-create" onClick={handleCreateWorktree}>
|
||||
<Icon name="plus" size="small" />
|
||||
@@ -816,14 +904,17 @@ const AgentManagerContent: Component = () => {
|
||||
>
|
||||
<span class="am-item-title">{s.title || "Untitled"}</span>
|
||||
<span class="am-item-time">{formatRelativeDate(s.updatedAt)}</span>
|
||||
<IconButton
|
||||
icon="branch"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Open in worktree"
|
||||
class="am-item-promote"
|
||||
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
|
||||
/>
|
||||
<div class="am-item-promote">
|
||||
<TooltipKeybind title="Open in worktree" keybind={kb().newWorktree ?? ""} placement="right">
|
||||
<IconButton
|
||||
icon="branch"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Open in worktree"
|
||||
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
@@ -854,10 +945,18 @@ const AgentManagerContent: Component = () => {
|
||||
pending
|
||||
? s.id === activePendingId() && !session.currentSessionID()
|
||||
: s.id === session.currentSessionID()
|
||||
const tabDirection = () => {
|
||||
if (active()) return ""
|
||||
const ids = activeTabs().map((t) => t.id)
|
||||
const activeId = session.currentSessionID() ?? activePendingId() ?? ""
|
||||
return adjacentHint(s.id, activeId, ids, kb().previousTab ?? "", kb().nextTab ?? "")
|
||||
}
|
||||
return (
|
||||
<SortableTab
|
||||
tab={s}
|
||||
active={active()}
|
||||
keybind={tabDirection()}
|
||||
closeKeybind={kb().closeTab ?? ""}
|
||||
onSelect={() => {
|
||||
if (pending) {
|
||||
setActivePendingId(s.id)
|
||||
@@ -877,16 +976,18 @@ const AgentManagerContent: Component = () => {
|
||||
</div>
|
||||
<div class={`am-tab-fade am-tab-fade-right ${tabScroll.showRight() ? "am-tab-fade-visible" : ""}`} />
|
||||
</div>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={`New session (${modKey}T)`}
|
||||
class="am-tab-add"
|
||||
onClick={handleAddSession}
|
||||
/>
|
||||
<TooltipKeybind title="New session" keybind={kb().newTab ?? ""} placement="bottom">
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="New session"
|
||||
class="am-tab-add"
|
||||
onClick={handleAddSession}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<div class="am-tab-terminal">
|
||||
<Tooltip value="Open Terminal" placement="bottom">
|
||||
<TooltipKeybind title="Terminal" keybind={kb().showTerminal ?? ""} placement="bottom">
|
||||
<IconButton
|
||||
icon="console"
|
||||
size="small"
|
||||
@@ -897,7 +998,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
@@ -921,7 +1022,7 @@ const AgentManagerContent: Component = () => {
|
||||
<div class="am-empty-state-text">No sessions open</div>
|
||||
<Button variant="primary" size="small" onClick={handleAddSession}>
|
||||
New session
|
||||
<span class="am-shortcut-hint">{modKey}T</span>
|
||||
<span class="am-shortcut-hint">{kb().newTab ?? ""}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -122,11 +122,16 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
/* Worktree item — larger card style */
|
||||
|
||||
.am-worktree-list [data-slot="hover-card-trigger"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.am-worktree-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -137,6 +142,8 @@
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-base);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.am-worktree-item:hover {
|
||||
@@ -628,3 +635,87 @@
|
||||
opacity: 0.6;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* HoverCard popover for worktree items */
|
||||
|
||||
.am-hover-card {
|
||||
padding: 10px 12px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.am-hover-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.am-hover-card-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-weaker);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.am-hover-card-branch {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.am-hover-card-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-weaker);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.am-hover-card-keybind {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-inset-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.am-hover-card-divider {
|
||||
height: 1px;
|
||||
background: var(--border-weak-base);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.am-hover-card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.am-hover-card-row-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
.am-hover-card-row-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,34 @@ export function validateLocalSession(persisted: string | undefined, ids: string[
|
||||
return persisted
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the keybinding hint for an item adjacent to the active item.
|
||||
* Only returns a hint when the item is exactly one step away in the flat list.
|
||||
* Returns empty string for non-adjacent items or the active item itself.
|
||||
*
|
||||
* @param itemId - The item being hovered
|
||||
* @param activeId - The currently selected/active item (or undefined for LOCAL)
|
||||
* @param flatIds - The full ordered sidebar list (LOCAL first, then worktrees, then sessions)
|
||||
* @param prev - Display string for "go up" (e.g. "⌘↑" or keybinding)
|
||||
* @param next - Display string for "go down" (e.g. "⌘↓" or keybinding)
|
||||
*/
|
||||
export function adjacentHint(
|
||||
itemId: string,
|
||||
activeId: string | undefined,
|
||||
flatIds: string[],
|
||||
prev: string,
|
||||
next: string,
|
||||
): string {
|
||||
if (!activeId || itemId === activeId) return ""
|
||||
const activeIdx = flatIds.indexOf(activeId)
|
||||
const itemIdx = flatIds.indexOf(itemId)
|
||||
if (activeIdx === -1 || itemIdx === -1) return ""
|
||||
const diff = itemIdx - activeIdx
|
||||
if (diff === -1) return prev
|
||||
if (diff === 1) return next
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* After removing a worktree, pick the nearest remaining sidebar neighbor.
|
||||
* Order: the worktree just below → the one above → LOCAL.
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Transformer } from "@thisbeyond/solid-dnd"
|
||||
import { createRoot } from "solid-js"
|
||||
import type { SessionInfo } from "../src/types/messages"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
|
||||
/** Lock drag movement to the X axis (horizontal-only tab dragging). */
|
||||
export const ConstrainDragYAxis: Component = () => {
|
||||
@@ -33,6 +33,8 @@ export const ConstrainDragYAxis: Component = () => {
|
||||
export const SortableTab: Component<{
|
||||
tab: SessionInfo
|
||||
active: boolean
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
onSelect: () => void
|
||||
onMiddleClick: (e: MouseEvent) => void
|
||||
onClose: (e: MouseEvent) => void
|
||||
@@ -47,23 +49,30 @@ export const SortableTab: Component<{
|
||||
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
|
||||
data-tab-id={props.tab.id}
|
||||
>
|
||||
<Tooltip value={props.tab.title || "Untitled"} placement="bottom">
|
||||
<TooltipKeybind
|
||||
title={props.tab.title || "Untitled"}
|
||||
keybind={props.keybind ?? ""}
|
||||
placement="bottom"
|
||||
inactive={props.active}
|
||||
>
|
||||
<div
|
||||
class={`am-tab ${props.active ? "am-tab-active" : ""}`}
|
||||
onClick={props.onSelect}
|
||||
onMouseDown={props.onMiddleClick}
|
||||
>
|
||||
<span class="am-tab-label">{props.tab.title || "Untitled"}</span>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Close tab"
|
||||
class="am-tab-close"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
<TooltipKeybind title="Close" keybind={props.closeKeybind ?? ""} placement="bottom">
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Close tab"
|
||||
class="am-tab-close"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -566,6 +566,12 @@ export interface AgentManagerStateMessage {
|
||||
tabOrder?: Record<string, string[]>
|
||||
}
|
||||
|
||||
// Resolved keybindings for agent manager actions
|
||||
export interface AgentManagerKeybindingsMessage {
|
||||
type: "agentManager.keybindings"
|
||||
bindings: Record<string, string>
|
||||
}
|
||||
|
||||
export type ExtensionMessage =
|
||||
| ReadyMessage
|
||||
| ConnectionStateMessage
|
||||
@@ -604,6 +610,7 @@ export type ExtensionMessage =
|
||||
| AgentManagerWorktreeSetupMessage
|
||||
| AgentManagerSessionAddedMessage
|
||||
| AgentManagerStateMessage
|
||||
| AgentManagerKeybindingsMessage
|
||||
| SetChatBoxMessage
|
||||
| TriggerTaskMessage
|
||||
|
||||
|
||||
Reference in New Issue
Block a user