diff --git a/CLAUDE.md b/CLAUDE.md index 317c7c58..be7b7fb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ navigation. - **lucide-react** v1.x for icons (note: brand icons like `Github` are NOT exported in this version — use `GitPullRequest` etc.) - **electron-builder** for packaging, signed with the user's personal Developer ID, notarized - **electron-updater** for OTA updates from GitHub releases -- **`@anthropic-ai/claude-code`** is bundled as a dep (pinned native binary) and used by json-mode tabs only. xterm Claude tabs continue to spawn the user's PATH `claude` so power users on bleeding-edge / beta builds keep that experience. Both share `~/.claude/` for auth + MCP config. +- **`@anthropic-ai/claude-code`** is bundled as a dep (pinned native binary) and used by Chat tabs (internally `json-mode`) only. Terminal tabs (internally xterm-hosted) continue to spawn the user's PATH `claude` so power users on bleeding-edge / beta builds keep that experience. Both share `~/.claude/` for auth + MCP config. ## Architecture (read this before touching state) @@ -429,10 +429,10 @@ hard dependency on `gh`. happens via the chip strip's `+` button (or `File → Add Backend…` if/when wired). Tokens encrypted in `secrets.enc` keyed `backend-token:`; connections list lives in `userData/config.json`. -- **Dual-claude model** — Harness ships two Claude Code binaries. **xterm - Claude tabs** spawn `/bin/zsh -ilc claude` so the user's PATH `claude` - is what runs (lets bleeding-edge / beta testers stay on their own - build). **json-mode tabs** spawn the bundled +- **Dual-claude model** — Harness ships two Claude Code binaries. **Terminal + tabs** (internally xterm-hosted) spawn `/bin/zsh -ilc claude` so the user's + PATH `claude` is what runs (lets bleeding-edge / beta testers stay on their + own build). **Chat tabs** (internally `json-mode`) spawn the bundled `@anthropic-ai/claude-code` native binary directly — pinned per Harness release so the `--permission-prompt-tool` round trip and stream-json schema can't drift between npm publishes. Both share `~/.claude/` for diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40c86d46..8020a452 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,7 +133,7 @@ The deep architecture documentation lives in [CLAUDE.md](CLAUDE.md). It's the or - **Anti-patterns to avoid in slices and derivers** — common mistakes that look fine in isolation but cause perf problems at scale (subscriber sweeps, reducer `.map()` allocations, etc.). - **How performance debugging works** — the perf log + HUD that we lean on when something feels slow. -If you're touching the json-mode chat specifically, the `plans/json-mode-native-chat.md` document has the live feature backlog and design notes. +If you're touching the Chat interface specifically (internally referred to as "json-mode"), the `plans/json-mode-native-chat.md` document has the live feature backlog and design notes. ## Code style diff --git a/src/main/build-initial-state.ts b/src/main/build-initial-state.ts index c1ce179f..bd6030a5 100644 --- a/src/main/build-initial-state.ts +++ b/src/main/build-initial-state.ts @@ -88,8 +88,8 @@ export function buildInitialAppState( wsTransportHost: config.wsTransportHost ?? '127.0.0.1', browserToolsEnabled: config.browserToolsEnabled !== false, browserToolsMode: config.browserToolsMode === 'view' ? 'view' : 'full', - jsonModeClaudeTabs: config.jsonModeClaudeTabs === true, defaultClaudeTabType: config.defaultClaudeTabType === 'json' ? 'json' : 'xterm', + chatPromotionDismissed: config.chatPromotionDismissed === true, autoApprovePermissions: config.autoApprovePermissions === true, autoApproveSteerInstructions: config.autoApproveSteerInstructions || '', useSystemClaudeForJsonMode: config.useSystemClaudeForJsonMode === true, diff --git a/src/main/desktop-shell.ts b/src/main/desktop-shell.ts index 0f853ed9..8ee52772 100644 --- a/src/main/desktop-shell.ts +++ b/src/main/desktop-shell.ts @@ -421,7 +421,15 @@ export function startDesktopShell(deps: DesktopShellStartDeps): DesktopShellStar { label: 'Debug: Crash Focused Tab', click: () => transport.sendSignal('app:debugCrashFocusedTab') - } + }, + ...(!app.isPackaged + ? [ + { + label: 'Debug: Preview Onboarding', + click: () => transport.sendSignal('app:debugPreviewOnboarding') + } as const + ] + : []) ] } ] diff --git a/src/main/index.ts b/src/main/index.ts index fe1ce574..a5c40ab3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -754,9 +754,6 @@ const panesFSM = new PanesFSM(store, { getDefaultAgentKind: () => toAgentKind(store.getSnapshot().state.settings.defaultAgent), getDefaultClaudeTabType: () => { const s = store.getSnapshot().state.settings - // The json-mode flag gates everything — when it's off, behave as if - // the default is xterm regardless of the per-type setting. - if (!s.jsonModeClaudeTabs) return 'xterm' return s.defaultClaudeTabType === 'json' ? 'json' : 'xterm' }, // Authoritative PTY teardown when tabs leave the tree. The renderer @@ -2648,20 +2645,6 @@ function registerIpcHandlers(): void { } ) - transport.onRequest('config:setJsonModeClaudeTabs', (_ctx, enabled: boolean) => { - if (enabled) { - config.jsonModeClaudeTabs = true - } else { - delete config.jsonModeClaudeTabs - } - saveConfig(config) - store.dispatch({ - type: 'settings/jsonModeClaudeTabsChanged', - payload: enabled - }) - return true - }) - transport.onRequest( 'config:setDefaultClaudeTabType', (_ctx, value: 'xterm' | 'json') => { @@ -2680,6 +2663,23 @@ function registerIpcHandlers(): void { } ) + transport.onRequest( + 'config:setChatPromotionDismissed', + (_ctx, value: boolean) => { + if (value) { + config.chatPromotionDismissed = true + } else { + delete config.chatPromotionDismissed + } + saveConfig(config) + store.dispatch({ + type: 'settings/chatPromotionDismissedChanged', + payload: value + }) + return true + } + ) + transport.onRequest( 'config:setJsonModeChatDensity', (_ctx, value: 'compact' | 'comfy') => { diff --git a/src/main/panes-fsm.ts b/src/main/panes-fsm.ts index e2625e4d..d63a1d40 100644 --- a/src/main/panes-fsm.ts +++ b/src/main/panes-fsm.ts @@ -24,10 +24,9 @@ interface PanesFSMOptions { getRepoRootForWorktree: (worktreePath: string) => string | undefined getLatestClaudeSessionId: (worktreePath: string) => Promise getDefaultAgentKind?: () => AgentKind - /** Read the JSON-mode Claude feature flag + default-tab-type setting. - * When the flag is on AND default is 'json', a default Claude agent - * tab gets spawned as a json-claude tab instead. Always returns - * 'xterm' (or undefined) when the feature flag is off. */ + /** Read the default Claude interface setting. When this returns 'json', + * a default Claude agent tab spawns as a json-claude tab instead of + * an xterm-hosted one. */ getDefaultClaudeTabType?: () => 'xterm' | 'json' /** Tear down the PTY backing a closed tab. Called for agent + shell * tabs when they're removed from the tree (closeTab, restartAgentTab, @@ -229,7 +228,7 @@ export class PanesFSM { agentTab = { id: sessionId, type: 'json-claude', - label: 'Claude (JSON)', + label: 'Chat', sessionId, mode: 'awake', model @@ -418,7 +417,7 @@ export class PanesFSM { newType === 'json-claude' ? sessionId : `agent-${wtPath.replace(/[^a-zA-Z0-9]/g, '-')}-${Date.now()}` - const newLabel = newType === 'json-claude' ? 'Claude (JSON)' : agentDisplayName('claude') + const newLabel = newType === 'json-claude' ? 'Chat' : agentDisplayName('claude') this.store.dispatch({ type: 'terminals/tabTypeChanged', payload: { worktreePath: wtPath, tabId, newId, newType, newLabel } diff --git a/src/main/persistence.ts b/src/main/persistence.ts index cc47a2f3..63cf2d5a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -173,14 +173,12 @@ export interface Config { // 'view' = inspect tabs + spawn/navigate, but no clicking, typing, or // scrolling. 'full' = everything. Default 'full' (undefined treated as 'full'). browserToolsMode?: 'view' | 'full' - // Experimental: enable the JSON-streamed React chat tab type as an - // alternative to xterm-hosted Claude tabs. Default off; gated by the - // jsonModeClaudeTabs setting in the renderer. - jsonModeClaudeTabs?: boolean - // When jsonModeClaudeTabs is on, controls whether the default Claude - // tab spawned by panes-fsm is the xterm TUI or the JSON-mode chat. - // Ignored when jsonModeClaudeTabs is off. + // Controls whether new Claude tabs spawn as the terminal-hosted TUI + // ('xterm') or the React chat interface ('json'). Default 'xterm'. defaultClaudeTabType?: 'xterm' | 'json' + // True once the user dismisses the "Switch to the new Chat mode" + // overlay shown on Terminal Claude tabs. + chatPromotionDismissed?: boolean // When true, JSON-mode tabs delegate per-tool approval decisions to a // Haiku oneshot for obviously-safe tool calls. Productivity feature // only — not a security boundary. Default off (undefined treated as diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index eedfaf87..b573cc7a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -26,6 +26,7 @@ import { WeeklyWrappedScreen } from './components/WeeklyWrappedScreen' import { Guide } from './components/Guide' import { AGENT_REGISTRY } from '../shared/agent-registry' import { AgentIcon } from './components/AgentIcon' +import { InterfaceToggle } from './components/InterfaceToggle' import { Activity } from './components/Activity' import { Cleanup } from './components/Cleanup' import { CommandCenter } from './components/CommandCenter' @@ -234,6 +235,11 @@ function DesktopApp(): JSX.Element { const [showNewProject, setShowNewProject] = useState(false) const [reportIssueState, setReportIssueState] = useState(null) const [showAddBackend, setShowAddBackend] = useState(false) + // Dev-only: forces the welcome / onboarding form to render even when + // the user already has repos added, so the layout can be inspected + // without wiping `userData/config.json`. Toggled from Help → Debug: + // Preview Onboarding (gated `!app.isPackaged`). + const [previewOnboarding, setPreviewOnboarding] = useState(false) const [crashedTabIds, setCrashedTabIds] = useState>(() => new Set()) // `theme` and `defaultAgent` are both seeded at init, so we track // explicit confirmation separately for the onboarding step checkmarks. @@ -385,6 +391,16 @@ const setQuestStep = useCallback((next: QuestStep) => { }) }, [activeWorktreeId, panes, activePaneId]) + // Debug: Preview Onboarding (dev-only Help menu entry). Toggles a + // renderer-local override that forces the welcome / onboarding form + // to render regardless of repo count — handy for inspecting the + // layout without wiping userData. + useEffect(() => { + return backend.onDebugPreviewOnboarding(() => { + setPreviewOnboarding((v) => !v) + }) + }, []) + // Trigger a full PR refresh in main. Used by the sidebar refresh button // and after worktree creation/removal. const fetchAllPRStatuses = useCallback(() => { @@ -732,7 +748,7 @@ const setQuestStep = useCallback((next: QuestStep) => { ) : null - if (repoRoots.length === 0) { + if (repoRoots.length === 0 || previewOnboarding) { const step1Complete = themeChosen const step2Complete = agentChosen const step3Complete = hooksConsent !== 'pending' @@ -747,6 +763,19 @@ const setQuestStep = useCallback((next: QuestStep) => {
+ {previewOnboarding && repoRoots.length > 0 && ( +
+ + Preview mode — viewing onboarding with {repoRoots.length} repo{repoRoots.length === 1 ? '' : 's'} already added. + + +
+ )}
@@ -919,6 +948,18 @@ const setQuestStep = useCallback((next: QuestStep) => { ))}
+ {agentChosen && defaultAgent === 'claude' && ( +
+
+ Which interface should new Claude tabs use? +
+ { void backend.setDefaultClaudeTabType(value) }} + size="compact" + /> +
+ )}
{ defaultAgent={defaultAgent ?? 'claude'} onAddAgentTab={(wt, kind, paneId) => handleAddAgentTab(wt, kind ?? defaultAgent ?? 'claude', paneId)} onAddBrowserTab={handleAddBrowserTab} - onAddJsonClaudeTab={ - settings.jsonModeClaudeTabs ? handleAddJsonClaudeTab : undefined - } - onConvertTabType={ - settings.jsonModeClaudeTabs ? handleConvertTabType : undefined - } - defaultClaudeTabType={ - settings.jsonModeClaudeTabs ? settings.defaultClaudeTabType : undefined - } + onAddJsonClaudeTab={handleAddJsonClaudeTab} + onConvertTabType={handleConvertTabType} + defaultClaudeTabType={settings.defaultClaudeTabType} onSleepTab={handleSleepTab} onCloseTab={handleCloseTab} onRestartAgentTab={handleRestartAgentTab} diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index 34ada6b6..b421fbf9 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -239,9 +239,10 @@ export function buildBackend( getLanAddresses: () => req('net:getLanAddresses'), setBrowserToolsEnabled: (enabled: boolean) => req('config:setBrowserToolsEnabled', enabled), setBrowserToolsMode: (mode: 'view' | 'full') => req('config:setBrowserToolsMode', mode), - setJsonModeClaudeTabs: (enabled: boolean) => req('config:setJsonModeClaudeTabs', enabled), setDefaultClaudeTabType: (value: 'xterm' | 'json') => req('config:setDefaultClaudeTabType', value), + setChatPromotionDismissed: (value: boolean) => + req('config:setChatPromotionDismissed', value), setJsonModeChatDensity: (value: 'compact' | 'comfy') => req('config:setJsonModeChatDensity', value), setJsonModeDefaultPermissionMode: (value: 'default' | 'acceptEdits' | 'plan') => @@ -412,6 +413,8 @@ export function buildBackend( onLocalSignal('app:openReportIssue', () => callback()), onDebugCrashFocusedTab: (callback: () => void) => onLocalSignal('app:debugCrashFocusedTab', () => callback()), + onDebugPreviewOnboarding: (callback: () => void) => + onLocalSignal('app:debugPreviewOnboarding', () => callback()), onOpenAddBackend: (callback: () => void) => onLocalSignal('app:openAddBackend', () => callback()), diff --git a/src/renderer/components/ActivityCosts.tsx b/src/renderer/components/ActivityCosts.tsx index c63eca92..efad8d1d 100644 --- a/src/renderer/components/ActivityCosts.tsx +++ b/src/renderer/components/ActivityCosts.tsx @@ -302,7 +302,7 @@ export function ActivityCosts(): JSX.Element { {!loading && repos.length === 0 && (
- No json-mode sessions in the selected period. + No Chat sessions in the selected period.
)} diff --git a/src/renderer/components/InterfaceToggle.tsx b/src/renderer/components/InterfaceToggle.tsx new file mode 100644 index 00000000..00862f14 --- /dev/null +++ b/src/renderer/components/InterfaceToggle.tsx @@ -0,0 +1,91 @@ +// Reused in Settings + Onboarding — single source of UI truth for the +// Terminal/Chat choice. The underlying setting value stays `'xterm' | 'json'` +// to keep internal code paths untouched. +import type { JSX } from 'react' +import { Terminal as TerminalIcon, MessageSquare } from 'lucide-react' + +export type ClaudeTabType = 'xterm' | 'json' + +interface InterfaceToggleProps { + value: ClaudeTabType + onChange: (next: ClaudeTabType) => void + /** Rendered inside Settings (wider, descriptions visible) or inside the + * QuestCard (narrower, descriptions trimmed). */ + size?: 'normal' | 'compact' +} + +const OPTIONS: Array<{ + value: ClaudeTabType + label: string + description: string + Icon: typeof TerminalIcon + badge?: 'new' +}> = [ + { + value: 'xterm', + label: 'Terminal mode', + description: "Claude Code's TUI in a shell tab.", + Icon: TerminalIcon + }, + { + value: 'json', + label: 'Chat mode', + description: + 'Native interface with inline tool cards and approval flows.', + Icon: MessageSquare, + badge: 'new' + } +] + +export function InterfaceToggle({ + value, + onChange, + size = 'normal' +}: InterfaceToggleProps): JSX.Element { + return ( +
+ {OPTIONS.map((opt) => { + const selected = value === opt.value + return ( + + ) + })} +
+ ) +} diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index adfdcac5..13be123e 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -22,12 +22,14 @@ import { X, Layers, RotateCcw, - ShieldAlert + ShieldAlert, + Sparkles } from 'lucide-react' import { useJsonClaudeSession, useSettings } from '../store' import { useBackend } from '../backend' import { useJsonClaudeApprovals } from '../hooks/useJsonClaudeApprovals' import { JsonClaudeApprovalCard } from './JsonClaudeApprovalCard' +import { Tooltip } from './Tooltip' import { dispatchToolCard, ToolCardChrome } from './json-mode-cards' import { ToolGroup } from './json-mode-cards/ToolGroup' import { TaskCard } from './json-mode-cards/TaskCard' @@ -856,7 +858,8 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo const backend = useBackend() const session = useJsonClaudeSession(sessionId) const { pending, resolve } = useJsonClaudeApprovals(sessionId) - const density = useSettings().jsonModeChatDensity + const { jsonModeChatDensity: density, defaultClaudeTabType } = useSettings() + const cameFromTerminalDefault = defaultClaudeTabType === 'xterm' const [draft, setDraft] = useState('') // Mention/popover state. `dismissed` carries the draft text at which // the user pressed Escape — comparing against the live draft is how we @@ -1588,6 +1591,28 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo
)} + {cameFromTerminalDefault && ( +
+ + + + + + +
+ )}
+ {entries.length === 0 && orphanApprovals.length === 0 && !busy ? ( +
+
+
+
+ +
+
+

+ What are we going to build today? +

+

+ Send a message to get started. +

+
+ ) : (
{groupedItems.map((g) => g.kind === 'single' ? ( @@ -1641,6 +1685,7 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo ) })()}
+ )}
{showJumpToBottom && ( ) : ( )}
diff --git a/src/renderer/components/QuestCard.tsx b/src/renderer/components/QuestCard.tsx index d144f250..ddcf4c33 100644 --- a/src/renderer/components/QuestCard.tsx +++ b/src/renderer/components/QuestCard.tsx @@ -16,8 +16,8 @@ export function QuestCard({ step, onDismiss, onFinish }: QuestCardProps): JSX.El return { icon: , eyebrow: 'Step 1 of 2', - title: 'One agent running. Don\u2019t wait \u2014 spawn another.', - body: 'The whole point of Harness is that you don\u2019t have to sit and watch. While this one works, fork a second worktree and give it a different task.', + title: 'One agent running. Don’t wait — spawn another.', + body: 'The whole point of Harness is that you don’t have to sit and watch. While this one works, fork a second worktree and give it a different task.', hint: ( <> Hit or use the sidebar to create another. @@ -44,7 +44,7 @@ export function QuestCard({ step, onDismiss, onFinish }: QuestCardProps): JSX.El icon: , eyebrow: 'Harnessed up', title: 'You just ran two agents in parallel.', - body: 'Do it with ten next time. Harness is happiest when it has a lot to juggle \u2014 the status dots keep you honest so nothing slips.', + body: 'Do it with ten next time. Harness is happiest when it has a lot to juggle — the status dots keep you honest so nothing slips.', hint: null } })() diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 2e71ec01..646bb3f3 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -9,6 +9,7 @@ import { DEFAULT_HOTKEYS, ACTION_LABELS, bindingToString, eventToBinding, resolv import { Tooltip } from './Tooltip' import { AGENT_REGISTRY, agentDisplayName, CLAUDE_MODELS, CODEX_MODELS } from '../../shared/agent-registry' import { AgentIcon } from './AgentIcon' +import { InterfaceToggle } from './InterfaceToggle' import { BUILT_IN_THEMES_BY_MODE, type ThemeOption } from '../themes' import { SEMANTIC_KEYS } from '../theme-apply' import type { CustomTheme } from '../../shared/state/settings' @@ -197,7 +198,6 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: wsTransportEnabled, wsTransportPort, wsTransportHost, - jsonModeClaudeTabs, defaultClaudeTabType, jsonModeChatDensity, jsonModeDefaultPermissionMode, @@ -1200,6 +1200,21 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:

New agent tabs will use the selected default. Existing tabs are unaffected.

+ + {defaultAgent === 'claude' && ( +
+ +

+ Which interface new Claude tabs spawn in. Switch any + existing tab from its right-click menu or the chip in + the tab header. +

+ { void backend.setDefaultClaudeTabType(value) }} + /> +
+ )}

@@ -1421,6 +1436,118 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: )}

+ {/* Chat interface settings — only relevant when running + Chat tabs, but always visible so the controls are findable. */} +
+ +

+ Behavior for Claude tabs running the Chat interface + (inline tool cards, approval flows). No effect on + Terminal tabs. +

+ +
+ +
+ New Chat tabs start in this mode. Change per-chat + anytime via the statusline picker. +
+ +
+ +
+ +
+ A Chat tab waiting for your reply for this long + (yellow dot) gets its subprocess torn down to free + RAM. Click the tab to wake — history is intact. Set + to 0 to disable. +
+
+ setAutoSleepDraft(e.target.value)} + onBlur={commitAutoSleepMinutes} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + className="bg-panel border border-border-strong rounded px-2 py-1 text-xs text-fg-bright outline-none focus:border-fg w-24" + /> + minutes +
+
+ +
+ +
+ Larger text and padding for new users or + screen-sharing. +
+
+ + +
+
+
+
{/* ── Codex subsection ── */} @@ -2396,166 +2523,6 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: - {/* JSON-mode Claude tabs sub-card */} -
-
-

JSON-mode Claude tabs

- - Experimental - -
-

- Adds a second Claude tab type that runs{' '} - claude -p --output-format stream-json{' '} - and renders the conversation in React — native textarea, real - text selection, markdown, syntax highlighting, per-tool cards. - When enabled, -click the - Sparkles button on a worktree's tab bar to spawn one. - Many TUI features are still missing — see{' '} - plans/json-mode-native-chat.md. -

- - - -
- -
- Which mode new Claude tabs spawn in. Per-tab swap is - available from the tab's right-click menu. -
- -
- -
- -
- New json-mode chats start in this mode. Change per-chat - anytime via the statusline picker. -
- -
- -
- -
- A json-mode tab waiting for your reply for this long (yellow - dot) gets its subprocess torn down to free RAM. Click the tab - to wake — history is intact. Set to 0 to disable. -
-
- setAutoSleepDraft(e.target.value)} - onBlur={commitAutoSleepMinutes} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur() - } - }} - disabled={!jsonModeClaudeTabs} - className="bg-panel border border-border-strong rounded px-2 py-1 text-xs text-fg-bright outline-none focus:border-fg w-24 disabled:opacity-40 disabled:cursor-not-allowed" - /> - minutes -
-
- -
- -
- Larger text and padding for new users or screen-sharing. Has - no effect on xterm Claude tabs. -
-
- - -
-
-
- {/* Auto-approve safe tool calls sub-card */}
@@ -2565,7 +2532,7 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:

- In JSON-mode tabs, spawns a Haiku oneshot to approve obviously-safe tool calls (Read, Grep, Edit, …) instead of prompting you. A hardcoded deny-list catches risky calls (rm -rf, git push, WebFetch, …) before Haiku is consulted. Productivity feature only — an LLM judging another LLM is not a security boundary. Has no effect on xterm Claude tabs. + In Chat tabs, spawns a Haiku oneshot to approve obviously-safe tool calls (Read, Grep, Edit, …) instead of prompting you. A hardcoded deny-list catches risky calls (rm -rf, git push, WebFetch, …) before Haiku is consulted. Productivity feature only — an LLM judging another LLM is not a security boundary. Has no effect on Terminal tabs.

@@ -326,9 +321,9 @@ export function TerminalPanel({ }, [pane.id, registerSlot]) const activeTab = pane.tabs.find((t) => t.id === pane.activeTabId) - // Spectator chip only makes sense for xterm-backed tabs. JSON-mode - // agent tabs (when they land) re-render per client, so the controller/ - // spectator concept doesn't apply. + // Spectator chip only makes sense for terminal-backed tabs. Chat tabs + // re-render per client, so the controller/spectator concept doesn't + // apply. const showSpectatorChip = !!activeTab && (activeTab.type === 'agent' || activeTab.type === 'shell') @@ -377,18 +372,18 @@ export function TerminalPanel({ { - const jsonIsDefault = !!onAddJsonClaudeTab && defaultClaudeTabType === 'json' - const plain = jsonIsDefault - ? 'New Claude (JSON) tab' + const chatIsDefault = !!onAddJsonClaudeTab && defaultClaudeTabType === 'json' + const plain = chatIsDefault + ? 'New Chat tab' : `New ${agentDisplayName(defaultAgent)} tab` const altPart = AGENT_REGISTRY.length > 1 ? ` · ⌥-click for ${agentDisplayName(AGENT_REGISTRY.find((a) => a.kind !== defaultAgent)?.kind)}` : '' const shiftPart = onAddJsonClaudeTab - ? jsonIsDefault - ? ` · ⇧-click for ${agentDisplayName('claude')} (xterm)` - : ' · ⇧-click for Claude (JSON, experimental)' + ? chatIsDefault + ? ` · ⇧-click for Terminal mode` + : ' · ⇧-click for Chat mode' : '' return plain + altPart + shiftPart })()} @@ -397,11 +392,11 @@ export function TerminalPanel({ onClick={(e) => { // Modifier precedence: // alt → other registered agent (Codex when default is - // Claude, vice versa) — independent of json/xterm. - // shift → "the other Claude tab type" relative to the + // Claude, vice versa) — independent of Chat/Terminal. + // shift → "the other Claude interface" relative to the // user's defaultClaudeTabType setting. - // plain → the default Claude tab type. - const jsonIsDefault = + // plain → the default Claude interface. + const chatIsDefault = !!onAddJsonClaudeTab && defaultClaudeTabType === 'json' if (e.altKey && AGENT_REGISTRY.length > 1) { const other = AGENT_REGISTRY.find((a) => a.kind !== defaultAgent) @@ -409,11 +404,11 @@ export function TerminalPanel({ return } if (e.shiftKey && onAddJsonClaudeTab) { - if (jsonIsDefault) onAddAgentTab('claude') + if (chatIsDefault) onAddAgentTab('claude') else onAddJsonClaudeTab() return } - if (jsonIsDefault) onAddJsonClaudeTab!() + if (chatIsDefault) onAddJsonClaudeTab!() else onAddAgentTab(defaultAgent) }} className="no-drag shrink-0 px-2 h-full text-faint hover:text-fg text-sm transition-colors cursor-pointer" diff --git a/src/renderer/components/WorkspaceView.tsx b/src/renderer/components/WorkspaceView.tsx index a84fb1d0..575bdbbb 100644 --- a/src/renderer/components/WorkspaceView.tsx +++ b/src/renderer/components/WorkspaceView.tsx @@ -37,14 +37,12 @@ interface WorkspaceViewProps { defaultAgent: AgentKind onAddAgentTab: (worktreePath: string, agentKind?: AgentKind, paneId?: string) => void onAddBrowserTab: (worktreePath: string, paneId?: string) => void - /** Only defined when the jsonModeClaudeTabs feature flag is on. */ onAddJsonClaudeTab?: (worktreePath: string, paneId?: string) => void - /** Convert a tab between xterm and JSON-mode in place. Only defined - * when the jsonModeClaudeTabs feature flag is on. */ + /** Convert a Claude tab between Terminal and Chat in place. */ onConvertTabType?: (worktreePath: string, tabId: string, newType: 'agent' | 'json-claude') => void - /** Drives whether the Sparkles button's plain click spawns xterm - * or json-claude (and which one the shift modifier flips to). - * Only meaningful when `onAddJsonClaudeTab` is defined. */ + /** Drives whether the Sparkles button's plain click spawns Terminal + * ('xterm') or Chat ('json'), and which one the shift modifier flips + * to. */ defaultClaudeTabType?: 'xterm' | 'json' onSleepTab: (worktreePath: string, tabId: string) => void onCloseTab: (worktreePath: string, tabId: string) => void @@ -590,6 +588,11 @@ export function WorkspaceView({ ? (): void => onRestartAgentTab(worktreePath, tab.id) : undefined } + onSwitchToChat={ + tab.type === 'agent' && tab.agentKind === 'claude' && onConvertTabType + ? (): void => onConvertTabType(worktreePath, tab.id, 'json-claude') + : undefined + } /> )} diff --git a/src/renderer/components/XTerminal.tsx b/src/renderer/components/XTerminal.tsx index b29b6581..1079b6f5 100644 --- a/src/renderer/components/XTerminal.tsx +++ b/src/renderer/components/XTerminal.tsx @@ -6,9 +6,10 @@ import { ProgressAddon } from '@xterm/addon-progress' import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' import type { StateEvent } from '../../shared/state' -import { getClientId, subscribeActiveTransportReconnect, useTerminalSession } from '../store' +import { getClientId, subscribeActiveTransportReconnect, useSettings, useTerminalSession } from '../store' import { getBackend, useBackend } from '../backend' -import { Eye, X } from 'lucide-react' +import { Eye, X, Sparkles } from 'lucide-react' +import { Tooltip } from './Tooltip' function ClaudeLoader() { return ( @@ -232,14 +233,18 @@ interface XTerminalProps { * `cwd` (the worktree root); absolute paths are used as-is. */ shellCwd?: string onRestartAgent?: () => void + /** When provided AND this is a Claude agent tab, an overlay chip in + * the top-left invites the user to switch to the Chat interface. */ + onSwitchToChat?: () => void } -export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionName, sessionId, initialPrompt, teleportSessionId, modelOverride, shellCommand, shellCwd, onRestartAgent }: XTerminalProps): JSX.Element { +export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionName, sessionId, initialPrompt, teleportSessionId, modelOverride, shellCommand, shellCwd, onRestartAgent, onSwitchToChat }: XTerminalProps): JSX.Element { // Lazy font-cache init — fires once on first XTerminal mount. See // initFontCache() comment for why this is lazy rather than at module // top. initFontCache() const backend = useBackend() + const chatPromotionDismissed = useSettings().chatPromotionDismissed const [exited, setExited] = useState(false) const containerRef = useRef(null) const terminalRef = useRef(null) @@ -846,6 +851,28 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa )} + {!loading && !exited && onSwitchToChat && type === 'agent' && agentKind === 'claude' && !chatPromotionDismissed && ( +
+ + + + + + +
+ )} {exited && type === 'agent' && onRestartAgent && (
diff --git a/src/renderer/hooks/useTabHandlers.ts b/src/renderer/hooks/useTabHandlers.ts index 57437e64..5447927d 100644 --- a/src/renderer/hooks/useTabHandlers.ts +++ b/src/renderer/hooks/useTabHandlers.ts @@ -83,8 +83,8 @@ export function useTabHandlers({ const handleAddJsonClaudeTab = useCallback( (worktreePath: string, paneId?: string) => { - // JSON-mode Claude tabs use a UUID for both tab id and session id — - // the manager passes it to `claude --session-id` directly so the + // Chat (json-claude) tabs use a UUID for both tab id and session id + // — the manager passes it to `claude --session-id` directly so the // session jsonl reuses the same identifier and survives a reload. const sessionId = crypto.randomUUID() appendTabToPane( @@ -92,7 +92,7 @@ export function useTabHandlers({ { id: sessionId, type: 'json-claude', - label: 'Claude (JSON)', + label: 'Chat', sessionId }, paneId diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 71aeecdc..1bd22805 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -299,8 +299,8 @@ export interface ElectronAPI { getLanAddresses(): Promise> setBrowserToolsEnabled(enabled: boolean): Promise setBrowserToolsMode(mode: 'view' | 'full'): Promise - setJsonModeClaudeTabs(enabled: boolean): Promise setDefaultClaudeTabType(value: 'xterm' | 'json'): Promise + setChatPromotionDismissed(value: boolean): Promise setJsonModeChatDensity(value: 'compact' | 'comfy'): Promise setJsonModeDefaultPermissionMode( value: 'default' | 'acceptEdits' | 'plan' @@ -435,6 +435,7 @@ export interface ElectronAPI { onOpenNewProject(callback: () => void): () => void onOpenReportIssue(callback: () => void): () => void onDebugCrashFocusedTab(callback: () => void): () => void + onDebugPreviewOnboarding(callback: () => void): () => void onOpenAddBackend(callback: () => void): () => void acceptHooks(): Promise diff --git a/src/shared/state/settings.test.ts b/src/shared/state/settings.test.ts index 3ca67b36..900188a8 100644 --- a/src/shared/state/settings.test.ts +++ b/src/shared/state/settings.test.ts @@ -355,18 +355,18 @@ describe('settingsReducer', () => { expect(full.browserToolsMode).toBe('full') }) - it('jsonModeClaudeTabsChanged toggles the experimental tab type', () => { - expect(initialSettings.jsonModeClaudeTabs).toBe(false) + it('chatPromotionDismissedChanged toggles the flag', () => { + expect(initialSettings.chatPromotionDismissed).toBe(false) const on = apply(initialSettings, { - type: 'settings/jsonModeClaudeTabsChanged', + type: 'settings/chatPromotionDismissedChanged', payload: true }) - expect(on.jsonModeClaudeTabs).toBe(true) + expect(on.chatPromotionDismissed).toBe(true) const off = apply(on, { - type: 'settings/jsonModeClaudeTabsChanged', + type: 'settings/chatPromotionDismissedChanged', payload: false }) - expect(off.jsonModeClaudeTabs).toBe(false) + expect(off.chatPromotionDismissed).toBe(false) }) it('defaultClaudeTabTypeChanged switches between xterm and json', () => { @@ -431,8 +431,7 @@ describe('settingsReducer', () => { expect(initialSettings.jsonModeDefaultPermissionMode).toBe('acceptEdits') const start: SettingsState = { ...initialSettings, - claudeCommand: 'pre-existing', - jsonModeClaudeTabs: true + claudeCommand: 'pre-existing' } const planned = apply(start, { type: 'settings/jsonModeDefaultPermissionModeChanged', @@ -440,7 +439,6 @@ describe('settingsReducer', () => { }) expect(planned.jsonModeDefaultPermissionMode).toBe('plan') expect(planned.claudeCommand).toBe('pre-existing') - expect(planned.jsonModeClaudeTabs).toBe(true) const back = apply(planned, { type: 'settings/jsonModeDefaultPermissionModeChanged', payload: 'default' diff --git a/src/shared/state/settings.ts b/src/shared/state/settings.ts index 4747787f..9a1720d4 100644 --- a/src/shared/state/settings.ts +++ b/src/shared/state/settings.ts @@ -98,14 +98,14 @@ export interface SettingsState { wsTransportHost: string browserToolsEnabled: boolean browserToolsMode: BrowserToolsMode - /** Experimental: when true, render Claude tabs as a JSON-streamed React - * chat (json-claude tab type) instead of an xterm-hosted TUI. Off by - * default. See plans/json-mode-native-chat.md. */ - jsonModeClaudeTabs: boolean - /** When `jsonModeClaudeTabs` is on, controls whether the Claude tab - * spawned by default is the xterm-hosted TUI or the JSON-mode React - * chat. Ignored when `jsonModeClaudeTabs` is off (always xterm). */ + /** Controls whether new Claude tabs spawn as the terminal-hosted TUI + * ('xterm') or the React chat interface ('json'). Internal values are + * unchanged; the user-facing label is "Terminal" / "Chat". */ defaultClaudeTabType: 'xterm' | 'json' + /** True once the user clicks the X on the "Switch to the new Chat + * mode" overlay shown on Terminal Claude tabs. Persistent so the + * promotion stays dismissed across reloads. */ + chatPromotionDismissed: boolean /** When true, JSON-mode tabs run a Haiku oneshot to auto-approve * obviously-safe tool calls instead of prompting the user. Productivity * feature only — an LLM judging another LLM is not a security boundary. @@ -199,8 +199,8 @@ export type SettingsEvent = | { type: 'settings/wsTransportHostChanged'; payload: string } | { type: 'settings/browserToolsEnabledChanged'; payload: boolean } | { type: 'settings/browserToolsModeChanged'; payload: BrowserToolsMode } - | { type: 'settings/jsonModeClaudeTabsChanged'; payload: boolean } | { type: 'settings/defaultClaudeTabTypeChanged'; payload: 'xterm' | 'json' } + | { type: 'settings/chatPromotionDismissedChanged'; payload: boolean } | { type: 'settings/autoApprovePermissionsChanged'; payload: boolean } | { type: 'settings/autoApproveSteerInstructionsChanged'; payload: string } | { type: 'settings/useSystemClaudeForJsonModeChanged'; payload: boolean } @@ -254,8 +254,8 @@ export const initialSettings: SettingsState = { wsTransportHost: '127.0.0.1', browserToolsEnabled: true, browserToolsMode: 'full', - jsonModeClaudeTabs: false, defaultClaudeTabType: 'xterm', + chatPromotionDismissed: false, autoApprovePermissions: false, autoApproveSteerInstructions: '', useSystemClaudeForJsonMode: false, @@ -341,10 +341,10 @@ export function settingsReducer(state: SettingsState, event: SettingsEvent): Set return { ...state, browserToolsEnabled: event.payload } case 'settings/browserToolsModeChanged': return { ...state, browserToolsMode: event.payload } - case 'settings/jsonModeClaudeTabsChanged': - return { ...state, jsonModeClaudeTabs: event.payload } case 'settings/defaultClaudeTabTypeChanged': return { ...state, defaultClaudeTabType: event.payload } + case 'settings/chatPromotionDismissedChanged': + return { ...state, chatPromotionDismissed: event.payload } case 'settings/autoApprovePermissionsChanged': return { ...state, autoApprovePermissions: event.payload } case 'settings/autoApproveSteerInstructionsChanged': diff --git a/src/shared/state/terminals.test.ts b/src/shared/state/terminals.test.ts index 62c9c615..cb90336a 100644 --- a/src/shared/state/terminals.test.ts +++ b/src/shared/state/terminals.test.ts @@ -560,7 +560,7 @@ describe('terminalsReducer', () => { tabId: 'agent-1', newId: 'sess-1', newType: 'json-claude', - newLabel: 'Claude (JSON)' + newLabel: 'Chat' } }) const leaves = getLeaves(next.panes['/wt/a']) @@ -568,7 +568,7 @@ describe('terminalsReducer', () => { expect(tab.type).toBe('json-claude') expect(tab.id).toBe('sess-1') expect(tab.sessionId).toBe('sess-1') - expect(tab.label).toBe('Claude (JSON)') + expect(tab.label).toBe('Chat') expect(leaves[0].activeTabId).toBe('sess-1') // Other tabs untouched. expect(leaves[0].tabs[1].id).toBe('shell-1') @@ -578,7 +578,7 @@ describe('terminalsReducer', () => { const tree: PaneNode = { type: 'leaf', id: 'p1', - tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1' }], + tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1' }], activeTabId: 'sess-1' } const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } @@ -617,7 +617,7 @@ describe('terminalsReducer', () => { tabId: 'agent-1', newId: 'sess-1', newType: 'json-claude', - newLabel: 'Claude (JSON)' + newLabel: 'Chat' } }) const tab = getLeaves(next.panes['/wt/a'])[0].tabs[0] @@ -630,8 +630,8 @@ describe('terminalsReducer', () => { type: 'leaf', id: 'p1', tabs: [ - { id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1', mode: 'awake' }, - { id: 'sess-2', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-2', mode: 'awake' } + { id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1', mode: 'awake' }, + { id: 'sess-2', type: 'json-claude', label: 'Chat', sessionId: 'sess-2', mode: 'awake' } ], activeTabId: 'sess-1' } @@ -651,7 +651,7 @@ describe('terminalsReducer', () => { const tree: PaneNode = { type: 'leaf', id: 'p1', - tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1', mode: 'asleep' }], + tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1', mode: 'asleep' }], activeTabId: 'sess-1' } const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } @@ -691,7 +691,7 @@ describe('terminalsReducer', () => { const tree: PaneNode = { type: 'leaf', id: 'p1', - tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1', mode: 'asleep' }], + tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1', mode: 'asleep' }], activeTabId: 'sess-1' } const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } @@ -706,7 +706,7 @@ describe('terminalsReducer', () => { const tree: PaneNode = { type: 'leaf', id: 'p1', - tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1', mode: 'awake' }], + tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1', mode: 'awake' }], activeTabId: 'sess-1' } const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } }