diff --git a/.claude/skills/terax-pr-workflow/SKILL.md b/.claude/skills/terax-pr-workflow/SKILL.md new file mode 100644 index 000000000..19718a59d --- /dev/null +++ b/.claude/skills/terax-pr-workflow/SKILL.md @@ -0,0 +1,112 @@ +--- +name: terax-pr-workflow +description: Use this skill whenever making ANY change to this repository — features, fixes, shortcuts, refactors, anything. It enforces the mandatory branch → commit → dual-PR (fork + upstream) → merge-fork → back-to-main workflow. Trigger on phrases like "create a PR", "add a feature", "fix this", "push this", "open a pull request", or any task that involves committing and shipping code changes. +user-invocable: true +--- + +# Terax PR Workflow + +Every change to this repo MUST follow this exact workflow. No exceptions. + +## Remotes + +- `origin` → `https://github.com/roberto-fernandino/terax-ai-fork.git` (the fork — you have write access) +- `upstream` → `https://github.com/crynta/terax-ai.git` (the original — NO push access, use cross-fork PRs) + +## Step-by-step + +### 1. Create a branch + +```bash +git checkout main +git pull origin main +git checkout -b feat/ +# or fix/, chore/, etc. +``` + +Branch name must be kebab-case and describe the change. + +### 2. Make the changes + +Implement the feature/fix. Type-check before committing: + +```bash +npx tsc --noEmit +``` + +### 3. Commit + +Stage only the relevant files (never `git add -A` blindly): + +```bash +git add +git commit -m "$(cat <<'EOF' +feat/fix/chore(scope): short imperative description + +Longer explanation if needed. + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +### 4. Push to fork + +```bash +git push origin +``` + +### 5. Create PR on the fork (roberto-fernandino/terax-ai-fork) + +```bash +gh pr create \ + --repo roberto-fernandino/terax-ai-fork \ + --base main \ + --head \ + --title "" \ + --body "$(cat <<'EOF' +## Summary +- bullet points + +## Test plan +- [ ] item + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +### 6. Create cross-fork PR on the upstream (crynta/terax-ai) + +Push access is denied to upstream, so use the fork branch as head: + +```bash +gh pr create \ + --repo crynta/terax-ai \ + --base main \ + --head roberto-fernandino: \ + --title "" \ + --body "..." +``` + +### 7. Merge the fork PR + +```bash +gh pr merge --repo roberto-fernandino/terax-ai-fork --merge --auto +``` + +### 8. Return to main and pull + +```bash +git checkout main +git pull origin main +``` + +## Rules + +- ALWAYS create a branch — never commit directly to main. +- ALWAYS open TWO PRs: one on the fork, one on the upstream (cross-fork). +- ALWAYS merge the fork PR and return to main at the end. +- NEVER push directly to upstream (no access); always use `--head roberto-fernandino:` for the upstream PR. +- Type-check (`npx tsc --noEmit`) before committing. +- Use conventional commit prefixes: `feat`, `fix`, `chore`, `refactor`, `docs`. diff --git a/package.json b/package.json index 1906318dc..9a17fab36 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.8.2", "license": "Apache-2.0", "type": "module", - "packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1", + "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", "engines": { "node": ">=22" }, diff --git a/src/app/App.tsx b/src/app/App.tsx index ad74bf712..5434078fe 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -11,6 +11,8 @@ import { quoteShellArg } from "@/lib/shellQuote"; import { useZoom } from "@/lib/useZoom"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { AgentNotificationsBridge, nextAttentionTarget } from "@/modules/agents"; +import { useAgentStore } from "@/modules/agents/store/agentStore"; +import { AgentsPanel } from "@/modules/agents-panel"; import { AgentRunBridge, AiMiniWindow, @@ -258,6 +260,21 @@ export default function App() { [tabs, activeSpaceId], ); + const agentStoreSessions = useAgentStore((s) => s.sessions); + const agentSessions = useMemo( + () => Object.values(agentStoreSessions), + [agentStoreSessions], + ); + const agentsByTabId = useMemo(() => { + const map = new Map(); + for (const s of agentSessions) { + map.set(s.tabId, s.agent); + } + return map; + }, [agentSessions]); + + const showAgentsTab = usePreferencesStore((s) => s.showAgentsTab); + const { sidebarRef, sidebarWidthRef, @@ -267,7 +284,7 @@ export default function App() { cycleSidebarView, persistSidebarWidth, toggleExplorerFocus, - } = useSidebarPanel(explorerRef); + } = useSidebarPanel(explorerRef, showAgentsTab); const [newEditorOpen, setNewEditorOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); @@ -284,6 +301,7 @@ export default function App() { const miniOpen = useChatStore((s) => s.mini.open); const miniPresence = usePresence(miniOpen, 200); const openMini = useChatStore((s) => s.openMini); + const toggleMini = useChatStore((s) => s.toggleMini); const focusInput = useChatStore((s) => s.focusInput); const openPanel = useChatStore((s) => s.openPanel); const panelOpen = useChatStore((s) => s.panelOpen); @@ -378,6 +396,23 @@ export default function App() { useEffect(() => { mruRef.current = [activeId, ...mruRef.current.filter((id) => id !== activeId)]; }, [activeId]); + + // When the user NAVIGATES to a terminal tab, reset any non-idle agent status + // back to idle — they are looking at it so notifications are moot, and + // Ctrl+C won't fire a finished signal so "working" would otherwise get stuck. + // Uses tabsRef (not tabs) so this only fires on activeId change, not on every + // tab title update (which would kill real-time "working" display). + useEffect(() => { + const tab = tabsRef.current.find((t) => t.id === activeId); + if (!tab || tab.kind !== "terminal") return; + const store = useAgentStore.getState(); + for (const s of Object.values(store.sessions)) { + if (s.tabId === activeId && s.status !== "idle") { + store.setStatus(s.leafId, "idle"); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeId]); useEffect(() => { const live = new Set(tabs.map((t) => t.id)); mruRef.current = mruRef.current.filter((id) => live.has(id)); @@ -653,7 +688,11 @@ export default function App() { "tab.close": handleCloseTabOrPane, "tab.next": () => stepSwitcher(1), "tab.prev": () => stepSwitcher(-1), - "tab.selectByIndex": (e) => selectByIndex(parseInt(e.key, 10) - 1), + "tab.selectByIndex": (e) => + selectByIndex( + parseInt(e.key, 10) - 1, + activeSpaceId ?? DEFAULT_SPACE_ID, + ), "space.next": () => cycleSpace(1), "space.prev": () => cycleSpace(-1), "space.overview": () => setSwitcherOpen(true), @@ -662,6 +701,7 @@ export default function App() { "pane.focusNext": () => focusNextPaneInTab(activeId, 1), "pane.focusPrev": () => focusNextPaneInTab(activeId, -1), "pane.source": toggleSourceControl, + "sidebar.files": () => cycleSidebarView("explorer"), "terminal.clear": () => { clearFocusedTerminal(); }, @@ -671,6 +711,13 @@ export default function App() { "blocks.next": () => navigateFocusedBlocks(1), "search.focus": () => searchInlineRef.current?.focus(), "ai.toggle": togglePanelAndFocus, + "ai.toggleMini": () => { + if (!hasComposer) { + void openSettingsWindow("models"); + return; + } + toggleMini(); + }, "ai.askSelection": askFromSelection, "agent.focusAttention": () => { const t = nextAttentionTarget(); @@ -700,7 +747,9 @@ export default function App() { splitActivePaneInActiveTab, focusNextPaneInTab, toggleSourceControl, + hasComposer, togglePanelAndFocus, + toggleMini, askFromSelection, toggleSidebar, toggleExplorerFocus, @@ -979,6 +1028,7 @@ export default function App() { openNewPreview: () => openPreviewTab(""), openGitGraph: openGitGraphFromContext, toggleSourceControl, + toggleFilesExplorer: () => cycleSidebarView("explorer"), closeActiveTabOrPane: handleCloseTabOrPane, splitPaneRight: () => splitActivePaneInActiveTab("row"), splitPaneDown: () => splitActivePaneInActiveTab("col"), @@ -994,6 +1044,12 @@ export default function App() { openSpacesOverview: () => setSwitcherOpen(true), newSpace: () => void handleNewSpace(), switchSpace: (id) => useSpaces.getState().setActive(id), + terminalTabs: tabs.filter( + (t) => + t.kind === "terminal" && + t.spaceId === (activeSpaceId ?? DEFAULT_SPACE_ID), + ), + switchTab: (id) => setActiveId(id), }) : [], [ @@ -1009,6 +1065,7 @@ export default function App() { openPreviewTab, openGitGraphFromContext, toggleSourceControl, + cycleSidebarView, handleCloseTabOrPane, splitActivePaneInActiveTab, toggleSidebar, @@ -1082,6 +1139,7 @@ export default function App() { searchTarget={searchTarget} searchRef={searchInlineRef} onOverrideLanguage={setOverrideLanguage} + agentsByTabId={agentsByTabId} /> )} @@ -1118,6 +1176,12 @@ export default function App() { onRevealInTerminal={cdInNewTab} onAttachToAgent={handleAttachFileToAgent} /> + ) : sidebarView === "agents" ? ( + ) : ( s.status !== "idle").length} /> diff --git a/src/modules/agents-panel/AgentsPanel.tsx b/src/modules/agents-panel/AgentsPanel.tsx new file mode 100644 index 000000000..b580ac79b --- /dev/null +++ b/src/modules/agents-panel/AgentsPanel.tsx @@ -0,0 +1,118 @@ +import { AgentIcon } from "@/modules/agents/lib/agentIcon"; +import { displayAgent } from "@/modules/agents/lib/format"; +import type { AgentSession, AgentStatus } from "@/modules/agents/lib/types"; +import type { Tab } from "@/modules/tabs/lib/useTabs"; +import { cn } from "@/lib/utils"; + +type Props = { + sessions: AgentSession[]; + tabs: Tab[]; + onSelectTab: (tabId: number) => void; +}; + +function StatusBadge({ status }: { status: AgentStatus }) { + if (status === "idle") { + return ( + + idle + + ); + } + return ( + + {status === "waiting" ? "needs input" : "working"} + + ); +} + +function tabTitleFor(tabs: Tab[], tabId: number): string | null { + const t = tabs.find((x) => x.id === tabId); + if (!t) return null; + if (t.kind === "terminal") return t.customTitle ?? t.title ?? null; + return null; +} + +export function AgentsPanel({ sessions, tabs, onSelectTab }: Props) { + if (sessions.length === 0) { + return ( +
+ +
+ + No active agents. + + + Start{" "} + claude,{" "} + codex,{" "} + gemini, + or{" "} + opencode{" "} + in a terminal, or use{" "} + + /claude-code + {" "} + in the AI chat. + +
+
+ ); + } + + return ( +
+ +
    + {sessions.map((s) => { + const tabTitle = tabTitleFor(tabs, s.tabId); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +function PanelHeader({ count }: { count?: number }) { + return ( +
+ + Agents + + {count !== undefined && count > 0 ? ( + + {count} + + ) : null} +
+ ); +} diff --git a/src/modules/agents-panel/index.ts b/src/modules/agents-panel/index.ts new file mode 100644 index 000000000..2bad6d4f7 --- /dev/null +++ b/src/modules/agents-panel/index.ts @@ -0,0 +1 @@ +export { AgentsPanel } from "./AgentsPanel"; diff --git a/src/modules/agents/components/AgentNotificationsBridge.tsx b/src/modules/agents/components/AgentNotificationsBridge.tsx index 76adf2507..786ea52e0 100644 --- a/src/modules/agents/components/AgentNotificationsBridge.tsx +++ b/src/modules/agents/components/AgentNotificationsBridge.tsx @@ -78,10 +78,10 @@ function handleSignal(sig: AgentSignal, ctx: Ctx): void { return; } case "finished": { - store.setStatus(leafId, "waiting"); const session = store.sessions[leafId]; if (session) route(session, "finished", ctx); maybeTriggerManagedReview(leafId); + store.setStatus(leafId, "idle"); return; } case "exited": diff --git a/src/modules/agents/components/NotificationBell.tsx b/src/modules/agents/components/NotificationBell.tsx index fb1b77781..4f8aa99a9 100644 --- a/src/modules/agents/components/NotificationBell.tsx +++ b/src/modules/agents/components/NotificationBell.tsx @@ -65,7 +65,7 @@ function StatusRow({ )} > {waiting ? : null} - {waiting ? "waiting" : "working"} + {waiting ? "waiting" : status === "idle" ? "idle" : "working"} ); diff --git a/src/modules/agents/lib/agentIcon.tsx b/src/modules/agents/lib/agentIcon.tsx index 0adb9ecd6..70f274908 100644 --- a/src/modules/agents/lib/agentIcon.tsx +++ b/src/modules/agents/lib/agentIcon.tsx @@ -10,7 +10,12 @@ function iconFor(agent: string): IconSvgElement { const a = agent.toLowerCase(); if (a.includes("claude")) return ClaudeIcon; if (a.includes("gemini")) return GoogleGeminiIcon; - if (a.includes("codex") || a.includes("gpt") || a.includes("openai")) + if ( + a.includes("codex") || + a.includes("gpt") || + a.includes("openai") || + a.includes("opencode") + ) return ChatGptIcon; return RoboticIcon; } diff --git a/src/modules/agents/lib/format.ts b/src/modules/agents/lib/format.ts index fca22a087..bf33c0dc9 100644 --- a/src/modules/agents/lib/format.ts +++ b/src/modules/agents/lib/format.ts @@ -2,6 +2,8 @@ const LABELS: Record = { claude: "Claude Code", codex: "Codex", gemini: "Gemini", + opencode: "OpenCode", + cursor: "Cursor Agent", terax: "Terax", }; diff --git a/src/modules/agents/lib/types.ts b/src/modules/agents/lib/types.ts index 631629a69..74f8cc0c0 100644 --- a/src/modules/agents/lib/types.ts +++ b/src/modules/agents/lib/types.ts @@ -1,4 +1,4 @@ -export type AgentStatus = "working" | "waiting"; +export type AgentStatus = "idle" | "working" | "waiting"; export type AgentSource = "terminal" | "local"; diff --git a/src/modules/agents/store/agentStore.ts b/src/modules/agents/store/agentStore.ts index 5877a2ba3..739d8f4fb 100644 --- a/src/modules/agents/store/agentStore.ts +++ b/src/modules/agents/store/agentStore.ts @@ -40,7 +40,7 @@ export const useAgentStore = create((set) => ({ leafId, tabId, agent, - status: "working", + status: "idle", startedAt: now, lastActivityAt: now, attentionSince: null, diff --git a/src/modules/ai/components/AiStatusBarControls.tsx b/src/modules/ai/components/AiStatusBarControls.tsx index 72578087b..e9d1698f7 100644 --- a/src/modules/ai/components/AiStatusBarControls.tsx +++ b/src/modules/ai/components/AiStatusBarControls.tsx @@ -170,9 +170,8 @@ export function AiStatusBarControls() { diff --git a/src/modules/ai/store/chatStore.ts b/src/modules/ai/store/chatStore.ts index d18ac28fd..7f64246b6 100644 --- a/src/modules/ai/store/chatStore.ts +++ b/src/modules/ai/store/chatStore.ts @@ -239,7 +239,7 @@ export const useChatStore = create((set, get) => ({ closeMini: () => set({ mini: { open: false } }), toggleMini: () => set((s) => ({ mini: { open: !s.mini.open } })), - panelOpen: false, + panelOpen: true, openPanel: () => set({ panelOpen: true }), closePanel: () => set({ panelOpen: false }), togglePanel: () => set((s) => ({ panelOpen: !s.panelOpen })), diff --git a/src/modules/command-palette/commands.ts b/src/modules/command-palette/commands.ts index 9f5e1c952..6dbf061d7 100644 --- a/src/modules/command-palette/commands.ts +++ b/src/modules/command-palette/commands.ts @@ -24,6 +24,7 @@ import type { PaletteItem } from "./types"; export const COMMAND_GROUPS = [ "General", "Spaces", + "Terminals", "Tabs", "Panes", "Git", @@ -45,6 +46,7 @@ export type CommandPaletteActionContext = { openNewPreview: () => void; openGitGraph: () => void; toggleSourceControl: () => void; + toggleFilesExplorer: () => void; closeActiveTabOrPane: () => void; splitPaneRight: () => void; splitPaneDown: () => void; @@ -60,6 +62,8 @@ export type CommandPaletteActionContext = { openSpacesOverview: () => void; newSpace: () => void; switchSpace: (id: string) => void; + terminalTabs: { id: number; title: string; customTitle?: string }[]; + switchTab: (id: number) => void; }; const noop = () => {}; @@ -134,6 +138,15 @@ export function createCommandItems( sp.id === ctx.activeSpaceId ? "Current space" : undefined, run: () => ctx.switchSpace(sp.id), })), + ...ctx.terminalTabs.map((tab) => ({ + id: `terminals.switch.${tab.id}`, + title: tab.customTitle || tab.title, + group: "Terminals" as const, + keywords: ["terminal", "switch", "tab", tab.customTitle ?? "", tab.title], + icon: TerminalIcon, + disabledReason: tab.id === ctx.activeId ? "Current tab" : undefined, + run: () => ctx.switchTab(tab.id), + })), { id: "tab.new", title: "New terminal", @@ -226,6 +239,15 @@ export function createCommandItems( shortcutId: "pane.source", run: ctx.toggleSourceControl, }, + { + id: "sidebar.files", + title: "Show Files sidebar", + group: "View", + keywords: ["files", "explorer", "sidebar", "file tree"], + icon: SidebarLeftIcon, + shortcutId: "sidebar.files", + run: ctx.toggleFilesExplorer, + }, { id: "search.content", title: "Find content in files", diff --git a/src/modules/header/Header.tsx b/src/modules/header/Header.tsx index 13da73950..1a6a0b8d9 100644 --- a/src/modules/header/Header.tsx +++ b/src/modules/header/Header.tsx @@ -41,6 +41,8 @@ type Props = { /** Move a dragged tab to a new position (insertion gap index). */ onReorder: (fromId: number, toGapIndex: number) => void; onOverrideLanguage?: (id: number, lang: string | null) => void; + /** Map from terminal tab id to running agent name string, for tab icon decoration. */ + agentsByTabId?: Map; onToggleSidebar: () => void; onOpenCommandPalette: () => void; onActivateAgent: (tabId: number, leafId: number) => void; @@ -68,6 +70,7 @@ export function Header({ onRename, onReorder, onOverrideLanguage, + agentsByTabId, onToggleSidebar, onOpenCommandPalette, onActivateAgent, @@ -164,6 +167,7 @@ export function Header({ onRename={onRename} onReorder={onReorder} onOverrideLanguage={onOverrideLanguage} + agentsByTabId={agentsByTabId} compact={compact} />
diff --git a/src/modules/settings/store.ts b/src/modules/settings/store.ts index 89bf433c2..a6b8e776b 100644 --- a/src/modules/settings/store.ts +++ b/src/modules/settings/store.ts @@ -109,6 +109,14 @@ export const EDITOR_THEME_LABELS: Record = { "xcode-light": "Xcode Light", }; +export type TerminalCursorStyle = "bar" | "block" | "underline"; + +export const TERMINAL_CURSOR_STYLES: { value: TerminalCursorStyle; label: string }[] = [ + { value: "bar", label: "Bar" }, + { value: "block", label: "Block" }, + { value: "underline", label: "Underline" }, +]; + export type Preferences = { theme: ThemePref; themeId: string; @@ -146,6 +154,7 @@ export type Preferences = { explorerGitDecorations: boolean; terminalWebglEnabled: boolean; terminalCursorBlink: boolean; + terminalCursorStyle: TerminalCursorStyle; terminalFontFamily: string; terminalFontWeight: string; terminalShell: string; @@ -155,6 +164,7 @@ export type Preferences = { lastWslDistro: string | null; zoomLevel: number; agentNotifications: boolean; + showAgentsTab: boolean; shortcuts: Record; editorAutoSave: boolean; editorAutoSaveDelay: number; @@ -198,6 +208,7 @@ const LEGACY_KEY_SHOW_HIDDEN_DIRS = "showHiddenDirectories"; const KEY_EXPLORER_GIT_DECORATIONS = "explorerGitDecorations"; const KEY_TERMINAL_WEBGL_ENABLED = "terminalWebglEnabled"; const KEY_TERMINAL_CURSOR_BLINK = "terminalCursorBlink"; +const KEY_TERMINAL_CURSOR_STYLE = "terminalCursorStyle"; const KEY_TERMINAL_FONT_FAMILY = "terminalFontFamily"; const KEY_TERMINAL_FONT_WEIGHT = "terminalFontWeight"; const KEY_TERMINAL_SHELL = "terminalShell"; @@ -207,6 +218,7 @@ const KEY_TERMINAL_SCROLLBACK = "terminalScrollback"; const KEY_LAST_WSL_DISTRO = "lastWslDistro"; const KEY_ZOOM_LEVEL = "zoomLevel"; const KEY_AGENT_NOTIFICATIONS = "agentNotifications"; +const KEY_SHOW_AGENTS_TAB = "showAgentsTab"; const KEY_SHORTCUTS = "shortcuts"; const KEY_EDITOR_AUTO_SAVE = "editorAutoSave"; const KEY_EDITOR_AUTO_SAVE_DELAY = "editorAutoSaveDelay"; @@ -263,6 +275,7 @@ export const DEFAULT_PREFERENCES: Preferences = { explorerGitDecorations: true, terminalWebglEnabled: true, terminalCursorBlink: false, + terminalCursorStyle: "bar", terminalFontFamily: "", terminalFontWeight: "normal", terminalShell: "", @@ -272,6 +285,7 @@ export const DEFAULT_PREFERENCES: Preferences = { lastWslDistro: null, zoomLevel: 1.0, agentNotifications: true, + showAgentsTab: true, shortcuts: {} as Record, editorAutoSave: false, editorAutoSaveDelay: 1000, @@ -401,6 +415,11 @@ export async function loadPreferences(): Promise { terminalCursorBlink: get(KEY_TERMINAL_CURSOR_BLINK) ?? DEFAULT_PREFERENCES.terminalCursorBlink, + terminalCursorStyle: ((): TerminalCursorStyle => { + const stored = get(KEY_TERMINAL_CURSOR_STYLE); + if (stored === "bar" || stored === "block" || stored === "underline") return stored; + return DEFAULT_PREFERENCES.terminalCursorStyle; + })(), terminalFontFamily: get(KEY_TERMINAL_FONT_FAMILY) ?? DEFAULT_PREFERENCES.terminalFontFamily, @@ -427,6 +446,8 @@ export async function loadPreferences(): Promise { agentNotifications: get(KEY_AGENT_NOTIFICATIONS) ?? DEFAULT_PREFERENCES.agentNotifications, + showAgentsTab: + get(KEY_SHOW_AGENTS_TAB) ?? DEFAULT_PREFERENCES.showAgentsTab, shortcuts: get>(KEY_SHORTCUTS) ?? DEFAULT_PREFERENCES.shortcuts, @@ -608,6 +629,10 @@ export async function setTerminalCursorBlink(value: boolean): Promise { await writePref(KEY_TERMINAL_CURSOR_BLINK, value); } +export async function setTerminalCursorStyle(value: TerminalCursorStyle): Promise { + await writePref(KEY_TERMINAL_CURSOR_STYLE, value); +} + export async function setTerminalFontFamily(value: string): Promise { await writePref(KEY_TERMINAL_FONT_FAMILY, value.trim()); } @@ -679,6 +704,10 @@ export async function setAgentNotifications(value: boolean): Promise { await writePref(KEY_AGENT_NOTIFICATIONS, value); } +export async function setShowAgentsTab(value: boolean): Promise { + await writePref(KEY_SHOW_AGENTS_TAB, value); +} + export async function setShortcuts( value: Record | {}, ): Promise { @@ -732,6 +761,7 @@ export async function onPreferencesChange( [KEY_EXPLORER_GIT_DECORATIONS]: "explorerGitDecorations", [KEY_TERMINAL_WEBGL_ENABLED]: "terminalWebglEnabled", [KEY_TERMINAL_CURSOR_BLINK]: "terminalCursorBlink", + [KEY_TERMINAL_CURSOR_STYLE]: "terminalCursorStyle", [KEY_TERMINAL_FONT_FAMILY]: "terminalFontFamily", [KEY_TERMINAL_FONT_WEIGHT]: "terminalFontWeight", [KEY_TERMINAL_SHELL]: "terminalShell", @@ -741,6 +771,7 @@ export async function onPreferencesChange( [KEY_LAST_WSL_DISTRO]: "lastWslDistro", [KEY_ZOOM_LEVEL]: "zoomLevel", [KEY_AGENT_NOTIFICATIONS]: "agentNotifications", + [KEY_SHOW_AGENTS_TAB]: "showAgentsTab", [KEY_SHORTCUTS]: "shortcuts", [KEY_EDITOR_AUTO_SAVE]: "editorAutoSave", [KEY_EDITOR_AUTO_SAVE_DELAY]: "editorAutoSaveDelay", diff --git a/src/modules/shortcuts/shortcuts.ts b/src/modules/shortcuts/shortcuts.ts index 7285a84ca..0d0acaba8 100644 --- a/src/modules/shortcuts/shortcuts.ts +++ b/src/modules/shortcuts/shortcuts.ts @@ -36,10 +36,12 @@ export type ShortcutId = | "view.zoomReset" | "view.zenMode" | "ai.toggle" + | "ai.toggleMini" | "ai.askSelection" | "agent.focusAttention" | "settings.open" | "sidebar.toggle" + | "sidebar.files" | "editor.undo" | "editor.redo"; @@ -241,12 +243,24 @@ export const SHORTCUTS: Shortcut[] = [ group: "AI", defaultBindings: [{ [MOD_PROP]: true, key: "i" }], }, + { + id: "ai.toggleMini", + label: "Toggle AI chat window", + group: "AI", + defaultBindings: [{ [MOD_PROP]: true, shift: true, key: "i" }], + }, { id: "ai.askSelection", label: "Ask AI about selection", group: "AI", defaultBindings: [{ [MOD_PROP]: true, key: "j" }], }, + { + id: "sidebar.files", + label: "Show Files sidebar", + group: "View", + defaultBindings: [{ [MOD_PROP]: true, shift: true, key: "g" }], + }, { id: "agent.focusAttention", label: "Jump to agent needing attention", diff --git a/src/modules/sidebar/SidebarRail.tsx b/src/modules/sidebar/SidebarRail.tsx index 56b0384d0..3b2b83093 100644 --- a/src/modules/sidebar/SidebarRail.tsx +++ b/src/modules/sidebar/SidebarRail.tsx @@ -1,5 +1,9 @@ import { cn } from "@/lib/utils"; -import { FolderGitTwoIcon, FolderTreeIcon } from "@hugeicons/core-free-icons"; +import { + FolderGitTwoIcon, + FolderTreeIcon, + RoboticIcon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import type { SidebarViewId } from "./types"; @@ -16,9 +20,17 @@ type Props = { activeView: SidebarViewId; onSelectView: (view: SidebarViewId) => void; changedCount: number; + showAgentsTab: boolean; + agentCount: number; }; -export function SidebarRail({ activeView, onSelectView, changedCount }: Props) { +export function SidebarRail({ + activeView, + onSelectView, + changedCount, + showAgentsTab, + agentCount, +}: Props) { const items: RailItem[] = [ { id: "explorer", label: "Files", icon: FolderTreeIcon }, { @@ -29,6 +41,15 @@ export function SidebarRail({ activeView, onSelectView, changedCount }: Props) { }, ]; + if (showAgentsTab) { + items.push({ + id: "agents", + label: "Agents", + icon: RoboticIcon, + badge: agentCount, + }); + } + return (
, + showAgentsTab: boolean, ) { const sidebarRef = useRef(null); const sidebarWidthRef = useRef(readSidebarWidth()); const sidebarWidthWriteTimerRef = useRef(0); const explorerReturnFocusRef = useRef(null); - const [sidebarView, setSidebarViewState] = - useState(readSidebarView); + const [sidebarView, setSidebarViewState] = useState(() => + readSidebarView(showAgentsTab), + ); const persistSidebarView = useCallback((view: SidebarViewId) => { setSidebarViewState(view); @@ -115,6 +118,12 @@ export function useSidebarPanel( }; }, []); + useEffect(() => { + if (!showAgentsTab && sidebarView === "agents") { + persistSidebarView("explorer"); + } + }, [showAgentsTab, sidebarView, persistSidebarView]); + const toggleExplorerFocus = useCallback(() => { const explorer = explorerRef.current; const panel = sidebarRef.current; diff --git a/src/modules/tabs/TabBar.tsx b/src/modules/tabs/TabBar.tsx index d1115f5cb..33852ef6e 100644 --- a/src/modules/tabs/TabBar.tsx +++ b/src/modules/tabs/TabBar.tsx @@ -43,12 +43,15 @@ import { useRef, useState, } from "react"; +import { AgentIcon } from "@/modules/agents/lib/agentIcon"; import { labelFor } from "./lib/tabLabel"; import type { EditorTab, Tab } from "./lib/useTabs"; type Props = { tabs: Tab[]; activeId: number; + /** Map from terminal tab id to running agent name, for icon decoration. */ + agentsByTabId?: Map; onSelect: (id: number) => void; onNew: () => void; onNewBlock: () => void; @@ -70,6 +73,7 @@ type Props = { export function TabBar({ tabs, activeId, + agentsByTabId, onSelect, onNew, onNewBlock, @@ -247,7 +251,7 @@ export function TabBar({ compact ? "px-1.5" : "px-2", )} > - + { @@ -359,7 +363,7 @@ export function TabBar({ data-no-drag className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-sm p-1 -m-1 transition-all hover:bg-accent hover:text-accent-foreground hover:ring-1 hover:ring-primary/30 hover:shadow-[0_0_4px_var(--color-popover-foreground)]" > - + ) : ( - + )} {/* Preview tabs use italic to signal the transient state, matching the visual convention from VSCode. */} @@ -619,7 +623,17 @@ function DropIndicator() { ); } -export function TabIcon({ tab }: { tab: Tab }) { +export function TabIcon({ + tab, + agentsByTabId, +}: { + tab: Tab; + agentsByTabId?: Map; +}) { + const agent = tab.kind === "terminal" ? agentsByTabId?.get(tab.id) : undefined; + if (agent) { + return ; + } if (tab.kind === "editor" || tab.kind === "markdown") { const url = tab.kind === "editor" && tab.overrideLanguage diff --git a/src/modules/tabs/lib/pickTabBySpaceIndex.test.ts b/src/modules/tabs/lib/pickTabBySpaceIndex.test.ts new file mode 100644 index 000000000..def7861f9 --- /dev/null +++ b/src/modules/tabs/lib/pickTabBySpaceIndex.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { pickTabBySpaceIndex, type Tab } from "./useTabs"; + +function term(id: number, spaceId: string): Tab { + return { + id, + kind: "terminal", + spaceId, + title: "shell", + paneTree: { kind: "leaf", id: id * 10 }, + activeLeafId: id * 10, + } as Tab; +} + +describe("pickTabBySpaceIndex", () => { + const tabs = [term(1, "a"), term(2, "b"), term(3, "b")]; + + it("Cmd+1 in space B returns B's first tab, not A's", () => { + expect(pickTabBySpaceIndex(tabs, 0, "b")?.id).toBe(2); + }); + + it("Cmd+2 in space B returns B's second tab", () => { + expect(pickTabBySpaceIndex(tabs, 1, "b")?.id).toBe(3); + }); + + it("Cmd+3 in space B returns undefined (does nothing)", () => { + expect(pickTabBySpaceIndex(tabs, 2, "b")).toBeUndefined(); + }); + + it("Cmd+1 in space A returns A's only tab", () => { + expect(pickTabBySpaceIndex(tabs, 0, "a")?.id).toBe(1); + }); + + it("returns undefined for an empty space", () => { + expect(pickTabBySpaceIndex(tabs, 0, "c")).toBeUndefined(); + }); +}); diff --git a/src/modules/tabs/lib/useTabs.ts b/src/modules/tabs/lib/useTabs.ts index 3abf5bba4..044578c35 100644 --- a/src/modules/tabs/lib/useTabs.ts +++ b/src/modules/tabs/lib/useTabs.ts @@ -148,6 +148,17 @@ function titleFromUrl(url: string): string { export const DEFAULT_SPACE_ID = "default"; +// Returns the tab at position `idx` within the given space, or undefined when +// idx is out of range or no matching space tab exists. +export function pickTabBySpaceIndex( + tabs: Tab[], + idx: number, + spaceId: string, +): Tab | undefined { + const pool = tabs.filter((t) => t.spaceId === spaceId); + return pool[idx]; +} + // Next active after close, scoped to the closing tab's space. null = last tab of // its space, which callers treat as "refuse to close". export function nextActiveInSpace( @@ -951,8 +962,10 @@ export function useTabs(initial?: Partial) { }, []); const selectByIndex = useCallback( - (idx: number) => { - const t = tabs[idx]; + (idx: number, spaceId?: string) => { + const t = spaceId + ? pickTabBySpaceIndex(tabs, idx, spaceId) + : tabs[idx]; if (t) setActiveId(t.id); }, [tabs], diff --git a/src/modules/terminal/lib/rendererPool.ts b/src/modules/terminal/lib/rendererPool.ts index 217afefe4..c2fd294ca 100644 --- a/src/modules/terminal/lib/rendererPool.ts +++ b/src/modules/terminal/lib/rendererPool.ts @@ -175,7 +175,7 @@ function termOptions() { fontSize: Math.max(4, Math.round(prefs.terminalFontSize * prefs.zoomLevel)), theme: buildTerminalTheme(), cursorBlink: false, - cursorStyle: "bar" as const, + cursorStyle: prefs.terminalCursorStyle, cursorInactiveStyle: "outline" as const, scrollback: prefs.terminalScrollback, allowProposedApi: true, @@ -933,6 +933,13 @@ export function applyScrollback(value: number): void { } } +export function applyCursorStyle(style: "bar" | "block" | "underline"): void { + for (const slot of slots) { + if (slot.term.options.cursorStyle === style) continue; + slot.term.options.cursorStyle = style; + } +} + export function applyTheme(): void { const theme = buildTerminalTheme(); for (const slot of slots) { diff --git a/src/modules/terminal/lib/useTerminalSession.ts b/src/modules/terminal/lib/useTerminalSession.ts index 5d14e73ee..0072fda0f 100644 --- a/src/modules/terminal/lib/useTerminalSession.ts +++ b/src/modules/terminal/lib/useTerminalSession.ts @@ -23,6 +23,7 @@ import { acquireSlot, applyBackgroundActive, applyCursorBlink, + applyCursorStyle, applyFontFamily, applyFontSize, applyFontWeight, @@ -907,6 +908,11 @@ export function useTerminalSession({ applyCursorBlink(cursorBlink); }, [cursorBlink]); + const cursorStyle = usePreferencesStore((p) => p.terminalCursorStyle); + useEffect(() => { + applyCursorStyle(cursorStyle); + }, [cursorStyle]); + const bgActive = usePreferencesStore( (p) => p.backgroundKind === "image" && !!p.backgroundImageId, ); diff --git a/src/settings/sections/GeneralSection.tsx b/src/settings/sections/GeneralSection.tsx index 3640f97b8..536bd5f46 100644 --- a/src/settings/sections/GeneralSection.tsx +++ b/src/settings/sections/GeneralSection.tsx @@ -20,7 +20,9 @@ import type { ThemePref } from "@/modules/settings/store"; import { TERMINAL_FONT_SIZES, TERMINAL_SCROLLBACK_PRESETS, + TERMINAL_CURSOR_STYLES, setAgentNotifications, + setShowAgentsTab, setAutostart, setEditorWordWrap, setEditorAutoSave, @@ -34,6 +36,7 @@ import { setTerminalLetterSpacing, setTerminalFontSize, setTerminalCursorBlink, + setTerminalCursorStyle, setTerminalScrollback, setTerminalWebglEnabled, setVimMode, @@ -98,6 +101,7 @@ export function GeneralSection() { const terminalCursorBlink = usePreferencesStore( (s) => s.terminalCursorBlink, ); + const terminalCursorStyle = usePreferencesStore((s) => s.terminalCursorStyle); const terminalFontFamily = usePreferencesStore((s) => s.terminalFontFamily); const terminalFontWeight = usePreferencesStore((s) => s.terminalFontWeight); const terminalShell = usePreferencesStore((s) => s.terminalShell); @@ -109,6 +113,7 @@ export function GeneralSection() { const terminalScrollback = usePreferencesStore((s) => s.terminalScrollback); const zoomLevel = usePreferencesStore((s) => s.zoomLevel); const agentNotifications = usePreferencesStore((s) => s.agentNotifications); + const showAgentsTab = usePreferencesStore((s) => s.showAgentsTab); useEffect(() => { let alive = true; @@ -300,6 +305,26 @@ export function GeneralSection() { onCheckedChange={(v) => void setTerminalCursorBlink(v)} /> + + + void setTerminalFontFamily(v)} @@ -441,6 +466,15 @@ export function GeneralSection() { onCheckedChange={(v) => void setAgentNotifications(v)} /> + + void setShowAgentsTab(v)} + /> +