From b50264525f76419f6272723550d76e51f23eaf1d Mon Sep 17 00:00:00 2001 From: Roberto Date: Fri, 26 Jun 2026 13:07:56 -0300 Subject: [PATCH 01/10] feat(tabs): select tabs by index within active space --- package.json | 2 +- src/app/App.tsx | 6 ++- .../tabs/lib/pickTabBySpaceIndex.test.ts | 37 +++++++++++++++++++ src/modules/tabs/lib/useTabs.ts | 17 ++++++++- 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 src/modules/tabs/lib/pickTabBySpaceIndex.test.ts 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 e9c5bd211..080ed208b 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -639,7 +639,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), 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], From 023baa17a029ac7e528470f02929a3586490a2ca Mon Sep 17 00:00:00 2001 From: Roberto Date: Fri, 26 Jun 2026 13:32:29 -0300 Subject: [PATCH 02/10] feat(shortcuts): add ai toggle mini shortcut --- src/app/App.tsx | 10 ++++++++++ src/modules/ai/components/AiStatusBarControls.tsx | 3 +-- src/modules/shortcuts/shortcuts.ts | 7 +++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 080ed208b..94da83746 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -284,6 +284,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); @@ -661,6 +662,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, "settings.open": () => void openSettingsWindow(), "sidebar.toggle": toggleSidebar, @@ -686,7 +694,9 @@ export default function App() { splitActivePaneInActiveTab, focusNextPaneInTab, toggleSourceControl, + hasComposer, togglePanelAndFocus, + toggleMini, askFromSelection, toggleSidebar, toggleExplorerFocus, 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/shortcuts/shortcuts.ts b/src/modules/shortcuts/shortcuts.ts index 376adba45..df7dc0991 100644 --- a/src/modules/shortcuts/shortcuts.ts +++ b/src/modules/shortcuts/shortcuts.ts @@ -36,6 +36,7 @@ export type ShortcutId = | "view.zoomReset" | "view.zenMode" | "ai.toggle" + | "ai.toggleMini" | "ai.askSelection" | "settings.open" | "sidebar.toggle" @@ -240,6 +241,12 @@ 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", From 2b644ff7a46ea67da6a1e7b08b13e4aed8aa6615 Mon Sep 17 00:00:00 2001 From: Roberto Date: Fri, 26 Jun 2026 17:52:52 -0300 Subject: [PATCH 03/10] feat: terminals in command-palette --- src/app/App.tsx | 6 ++++++ src/modules/command-palette/commands.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/app/App.tsx b/src/app/App.tsx index 94da83746..4ed06d654 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -995,6 +995,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), }) : [], [ diff --git a/src/modules/command-palette/commands.ts b/src/modules/command-palette/commands.ts index 9f5e1c952..4680a1b03 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", @@ -60,6 +61,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 +137,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", From 81e752f343a63d0f284460e457e191efc8c1b0af Mon Sep 17 00:00:00 2001 From: Roberto Date: Fri, 26 Jun 2026 21:12:10 -0300 Subject: [PATCH 04/10] feat(shortcuts): add configurable shortcut to show Files sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new keyboard shortcut (Cmd+Shift+G by default) that switches the sidebar to the Files/explorer view. Pairs naturally with Cmd+G (source control) so the user can toggle between them without touching the mouse. The shortcut uses cycleSidebarView("explorer") — same pattern as pane.source uses for source-control — and is fully configurable in Settings > Shortcuts. Also exposed as "Show Files sidebar" in the command palette. Co-Authored-By: Claude Sonnet 4.6 --- src/app/App.tsx | 3 +++ src/modules/command-palette/commands.ts | 10 ++++++++++ src/modules/shortcuts/shortcuts.ts | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/src/app/App.tsx b/src/app/App.tsx index 4ed06d654..0ddc49032 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -653,6 +653,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(); }, @@ -980,6 +981,7 @@ export default function App() { openNewPreview: () => openPreviewTab(""), openGitGraph: openGitGraphFromContext, toggleSourceControl, + toggleFilesExplorer: () => cycleSidebarView("explorer"), closeActiveTabOrPane: handleCloseTabOrPane, splitPaneRight: () => splitActivePaneInActiveTab("row"), splitPaneDown: () => splitActivePaneInActiveTab("col"), @@ -1016,6 +1018,7 @@ export default function App() { openPreviewTab, openGitGraphFromContext, toggleSourceControl, + cycleSidebarView, handleCloseTabOrPane, splitActivePaneInActiveTab, toggleSidebar, diff --git a/src/modules/command-palette/commands.ts b/src/modules/command-palette/commands.ts index 4680a1b03..6dbf061d7 100644 --- a/src/modules/command-palette/commands.ts +++ b/src/modules/command-palette/commands.ts @@ -46,6 +46,7 @@ export type CommandPaletteActionContext = { openNewPreview: () => void; openGitGraph: () => void; toggleSourceControl: () => void; + toggleFilesExplorer: () => void; closeActiveTabOrPane: () => void; splitPaneRight: () => void; splitPaneDown: () => void; @@ -238,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/shortcuts/shortcuts.ts b/src/modules/shortcuts/shortcuts.ts index df7dc0991..27aacc717 100644 --- a/src/modules/shortcuts/shortcuts.ts +++ b/src/modules/shortcuts/shortcuts.ts @@ -40,6 +40,7 @@ export type ShortcutId = | "ai.askSelection" | "settings.open" | "sidebar.toggle" + | "sidebar.files" | "editor.undo" | "editor.redo"; @@ -253,6 +254,12 @@ export const SHORTCUTS: Shortcut[] = [ 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: "sidebar.toggle", label: "Toggle file explorer", From 9bf6a2ddcc77a79e7d67b1ca8121fa35e52d107b Mon Sep 17 00:00:00 2001 From: Roberto Date: Sat, 27 Jun 2026 14:05:14 -0300 Subject: [PATCH 05/10] chore: add terax-pr-workflow project skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents and enforces the mandatory workflow: branch → commit → dual PR (fork + upstream cross-fork) → merge fork → back to main. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/terax-pr-workflow/SKILL.md | 112 ++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .claude/skills/terax-pr-workflow/SKILL.md 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`. From b679121c6b43c1c876d9434558930f1591741d95 Mon Sep 17 00:00:00 2001 From: Roberto Fernandino Date: Sat, 27 Jun 2026 18:24:52 -0300 Subject: [PATCH 06/10] feat(sidebar): add Agents tab showing all active AI agent sessions - New "Agents" sidebar tab lists every Claude Code, Codex, Gemini, OpenCode and Cursor agent running in terminals, with status badges (working / needs input) and one-click navigation to the terminal tab - Terminal tabs whose agent is detected by the backend now show that tool's logo (Claude, Gemini, Codex/OpenCode, or robotic fallback) instead of the generic terminal icon - "Show Agents sidebar tab" toggle in Settings > General (default on); disabling it falls back to the Files view - Merged upstream additions: Codex/Gemini notification hooks, per-agent notification bell, jump-to-attention shortcut Co-Authored-By: Claude Sonnet 4.6 --- src/app/App.tsx | 28 +++++- src/modules/agents-panel/AgentsPanel.tsx | 111 +++++++++++++++++++++++ src/modules/agents-panel/index.ts | 1 + src/modules/agents/lib/agentIcon.tsx | 7 +- src/modules/agents/lib/format.ts | 2 + src/modules/header/Header.tsx | 4 + src/modules/settings/store.ts | 10 ++ src/modules/sidebar/SidebarRail.tsx | 25 ++++- src/modules/sidebar/types.ts | 2 +- src/modules/sidebar/useSidebarPanel.ts | 15 ++- src/modules/tabs/TabBar.tsx | 22 ++++- src/settings/sections/GeneralSection.tsx | 11 +++ 12 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 src/modules/agents-panel/AgentsPanel.tsx create mode 100644 src/modules/agents-panel/index.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 98f2109f2..5dc741fbb 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); @@ -1105,6 +1122,7 @@ export default function App() { searchTarget={searchTarget} searchRef={searchInlineRef} onOverrideLanguage={setOverrideLanguage} + agentsByTabId={agentsByTabId} /> )} @@ -1141,6 +1159,12 @@ export default function App() { onRevealInTerminal={cdInNewTab} onAttachToAgent={handleAttachFileToAgent} /> + ) : sidebarView === "agents" ? ( + ) : ( diff --git a/src/modules/agents-panel/AgentsPanel.tsx b/src/modules/agents-panel/AgentsPanel.tsx new file mode 100644 index 000000000..9f63fee45 --- /dev/null +++ b/src/modules/agents-panel/AgentsPanel.tsx @@ -0,0 +1,111 @@ +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 }) { + 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/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/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..4dc85f349 100644 --- a/src/modules/settings/store.ts +++ b/src/modules/settings/store.ts @@ -155,6 +155,7 @@ export type Preferences = { lastWslDistro: string | null; zoomLevel: number; agentNotifications: boolean; + showAgentsTab: boolean; shortcuts: Record; editorAutoSave: boolean; editorAutoSaveDelay: number; @@ -207,6 +208,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"; @@ -272,6 +274,7 @@ export const DEFAULT_PREFERENCES: Preferences = { lastWslDistro: null, zoomLevel: 1.0, agentNotifications: true, + showAgentsTab: true, shortcuts: {} as Record, editorAutoSave: false, editorAutoSaveDelay: 1000, @@ -427,6 +430,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, @@ -679,6 +684,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 { @@ -741,6 +750,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/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/settings/sections/GeneralSection.tsx b/src/settings/sections/GeneralSection.tsx index 3640f97b8..eb2cb4c1b 100644 --- a/src/settings/sections/GeneralSection.tsx +++ b/src/settings/sections/GeneralSection.tsx @@ -21,6 +21,7 @@ import { TERMINAL_FONT_SIZES, TERMINAL_SCROLLBACK_PRESETS, setAgentNotifications, + setShowAgentsTab, setAutostart, setEditorWordWrap, setEditorAutoSave, @@ -109,6 +110,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; @@ -441,6 +443,15 @@ export function GeneralSection() { onCheckedChange={(v) => void setAgentNotifications(v)} /> + + void setShowAgentsTab(v)} + /> +
From bf8faf95d00feebe199f598a15cdcf29bfe0057e Mon Sep 17 00:00:00 2001 From: Roberto Fernandino Date: Sat, 27 Jun 2026 18:47:03 -0300 Subject: [PATCH 07/10] fix(agents): show idle status when agent is at prompt, not working When an agent process starts (claude, codex, etc.) it was immediately shown as "working" even though it was just sitting at its idle prompt. Added "idle" as a third AgentStatus. The "started" signal now sets idle; only the explicit "working" signal (fired when the agent begins executing a task) promotes it to working. The sidebar badge count excludes idle sessions so the badge only lights up during real activity. Co-Authored-By: Claude Sonnet 4.6 --- src/app/App.tsx | 2 +- src/modules/agents-panel/AgentsPanel.tsx | 7 +++++++ src/modules/agents/components/NotificationBell.tsx | 2 +- src/modules/agents/lib/types.ts | 2 +- src/modules/agents/store/agentStore.ts | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 5dc741fbb..9f0c56fef 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1181,7 +1181,7 @@ export default function App() { onSelectView={persistSidebarView} changedCount={sourceControl.changedCount} showAgentsTab={showAgentsTab} - agentCount={agentSessions.length} + agentCount={agentSessions.filter((s) => s.status !== "idle").length} />
diff --git a/src/modules/agents-panel/AgentsPanel.tsx b/src/modules/agents-panel/AgentsPanel.tsx index 9f63fee45..b580ac79b 100644 --- a/src/modules/agents-panel/AgentsPanel.tsx +++ b/src/modules/agents-panel/AgentsPanel.tsx @@ -11,6 +11,13 @@ type Props = { }; function StatusBadge({ status }: { status: AgentStatus }) { + if (status === "idle") { + return ( + + idle + + ); + } return ( {waiting ? : null} - {waiting ? "waiting" : "working"} + {waiting ? "waiting" : status === "idle" ? "idle" : "working"} ); 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, From 8a76492a34d5769382acabcee754beeaf0fb62a6 Mon Sep 17 00:00:00 2001 From: Roberto Fernandino Date: Sat, 27 Jun 2026 18:57:43 -0300 Subject: [PATCH 08/10] feat(ai): open AI panel by default on startup Co-Authored-By: Claude Sonnet 4.6 --- src/modules/ai/store/chatStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 })), From 497e6b8e9dd5f9da5e7ebaabe88a59cbb017223c Mon Sep 17 00:00:00 2001 From: Roberto Fernandino Date: Sat, 27 Jun 2026 20:40:08 -0300 Subject: [PATCH 09/10] fix(agents): clear needs-input when tab focused; finished resets to idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "finished" signal now sets status back to "idle" instead of "waiting" so the badge only lights up on genuine attention requests, not routine task completions - Added a useEffect in App.tsx: when the active tab becomes a terminal that has a waiting agent, reset it to idle — the user is already looking at it so the notification is no longer relevant; covers all nav paths (tab click, keyboard shortcut, notification bell, agents panel) Co-Authored-By: Claude Sonnet 4.6 --- src/app/App.tsx | 13 +++++++++++++ .../agents/components/AgentNotificationsBridge.tsx | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 9f0c56fef..f4df7dc16 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -396,6 +396,19 @@ export default function App() { useEffect(() => { mruRef.current = [activeId, ...mruRef.current.filter((id) => id !== activeId)]; }, [activeId]); + + // When the user navigates to a terminal tab, clear any "needs input" status + // on agents running in that tab — they can see the agent, no notification needed. + useEffect(() => { + const tab = tabs.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 === "waiting") { + store.setStatus(s.leafId, "idle"); + } + } + }, [activeId, tabs]); useEffect(() => { const live = new Set(tabs.map((t) => t.id)); mruRef.current = mruRef.current.filter((id) => live.has(id)); 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": From a893d448d25cad717015a5d2a331d29503dada2a Mon Sep 17 00:00:00 2001 From: Roberto Fernandino Date: Sun, 28 Jun 2026 13:44:22 -0300 Subject: [PATCH 10/10] fix(agents): reset working status to idle when terminal tab is focused Ctrl+C interrupts Claude Code without firing a finished signal, leaving the status permanently stuck on "working". Extend the tab-focus effect to reset any non-idle status (waiting or working) to idle when the user navigates to the terminal. If the agent is genuinely still running, the backend will fire the next working signal and promote it back immediately. Co-Authored-By: Claude Sonnet 4.6 --- src/app/App.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index f4df7dc16..09ad55e48 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -397,14 +397,17 @@ export default function App() { mruRef.current = [activeId, ...mruRef.current.filter((id) => id !== activeId)]; }, [activeId]); - // When the user navigates to a terminal tab, clear any "needs input" status - // on agents running in that tab — they can see the agent, no notification needed. + // When the user navigates to a terminal tab, reset any non-idle agent status + // back to idle — they are looking at the terminal so notifications are moot, + // and a Ctrl+C interrupt won't fire a finished signal so "working" would + // otherwise get stuck. If the agent is genuinely still working the backend + // will fire the next "working" signal and promote it back immediately. useEffect(() => { const tab = tabs.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 === "waiting") { + if (s.tabId === activeId && s.status !== "idle") { store.setStatus(s.leafId, "idle"); } }