feat: add real-time permission and model mode synchronization close #21

Implements bidirectional sync of permission/model modes between CLI sessions and web app. Adds Codex-specific permission modes (read-only, safe-yolo, yolo) alongside Claude's modes. Web can now control CLI session state via RPC set-session-config handler, while CLI broadcasts state changes through keep-alive payloads. UI controls are flavor-aware, showing appropriate modes for Claude vs Codex vs Gemini. Type centralization in api/types eliminates circular dependencies.
This commit is contained in:
weishu
2025-12-28 20:51:18 +08:00
parent 2dc09a80db
commit 334cebc2da
21 changed files with 361 additions and 120 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ export class ApiClient {
})
}
async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'): Promise<void> {
async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, {
method: 'POST',
body: JSON.stringify({ mode })
+3 -4
View File
@@ -12,17 +12,16 @@ import type { ModelMode } from '@/types/api'
*/
const CONTEXT_HEADROOM_TOKENS = 10_000
const MODEL_CONTEXT_WINDOWS: Record<NonNullable<ModelMode>, number> = {
const MODEL_CONTEXT_WINDOWS: Record<ModelMode, number> = {
// Claude Code modes used in this app; currently treated as ~200k context.
default: 200_000,
sonnet: 200_000,
opus: 200_000
}
export function getContextBudgetTokens(modelMode: ModelMode): number | null {
const mode: NonNullable<ModelMode> = modelMode ?? 'default'
export function getContextBudgetTokens(modelMode: ModelMode | undefined): number | null {
const mode: ModelMode = modelMode ?? 'default'
const windowTokens = MODEL_CONTEXT_WINDOWS[mode]
if (!windowTokens) return null
return Math.max(1, windowTokens - CONTEXT_HEADROOM_TOKENS)
}
@@ -26,12 +26,16 @@ export interface TextInputState {
selection: { start: number; end: number }
}
const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const
const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const
const CODEX_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const
const PERMISSION_MODE_LABELS: Record<string, string> = {
default: 'Default',
acceptEdits: 'Accept Edits',
plan: 'Plan Mode',
bypassPermissions: 'Bypass All'
bypassPermissions: 'Yolo',
'read-only': 'Read Only',
'safe-yolo': 'Safe Yolo',
yolo: 'Yolo'
}
const MODEL_MODES = ['default', 'sonnet', 'opus'] as const
@@ -52,6 +56,7 @@ export function HappyComposer(props: {
agentState?: AgentState | null
contextSize?: number
controlledByUser?: boolean
agentFlavor?: string | null
onPermissionModeChange?: (mode: PermissionMode) => void
onModelModeChange?: (mode: ModelMode) => void
onSwitchToRemote?: () => void
@@ -68,6 +73,7 @@ export function HappyComposer(props: {
agentState,
contextSize,
controlledByUser = false,
agentFlavor,
onPermissionModeChange,
onModelModeChange,
onSwitchToRemote,
@@ -76,7 +82,7 @@ export function HappyComposer(props: {
autocompleteSuggestions = defaultSuggestionHandler
} = props
// Use ?? to handle both null and undefined (destructuring defaults only work for undefined)
// Use ?? so missing values fall back to default (destructuring defaults only handle undefined)
const permissionMode = rawPermissionMode ?? 'default'
const modelMode = rawModelMode ?? 'default'
@@ -211,6 +217,16 @@ export function HappyComposer(props: {
}
}, [switchDisabled, onSwitchToRemote, haptic])
const permissionModes = useMemo(() => {
if (agentFlavor === 'codex') {
return CODEX_PERMISSION_MODES as readonly PermissionMode[]
}
if (agentFlavor === 'gemini') {
return [] as readonly PermissionMode[]
}
return CLAUDE_PERMISSION_MODES as readonly PermissionMode[]
}, [agentFlavor])
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
const key = e.key
@@ -249,11 +265,12 @@ export function HappyComposer(props: {
return
}
if (key === 'Tab' && e.shiftKey && onPermissionModeChange) {
if (key === 'Tab' && e.shiftKey && onPermissionModeChange && permissionModes.length > 0) {
e.preventDefault()
const currentIndex = PERMISSION_MODES.indexOf(permissionMode as typeof PERMISSION_MODES[number])
const nextIndex = (currentIndex + 1) % PERMISSION_MODES.length
onPermissionModeChange(PERMISSION_MODES[nextIndex])
const currentIndex = permissionModes.indexOf(permissionMode)
const nextIndex = (currentIndex + 1) % permissionModes.length
const nextMode = permissionModes[nextIndex] ?? 'default'
onPermissionModeChange(nextMode)
haptic('light')
}
}, [
@@ -267,12 +284,13 @@ export function HappyComposer(props: {
handleAbort,
onPermissionModeChange,
permissionMode,
permissionModes,
haptic
])
useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange) {
if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini') {
e.preventDefault()
const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number])
const nextIndex = (currentIndex + 1) % MODEL_MODES.length
@@ -283,7 +301,7 @@ export function HappyComposer(props: {
window.addEventListener('keydown', handleGlobalKeyDown)
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
}, [modelMode, onModelModeChange, haptic])
}, [modelMode, onModelModeChange, haptic, agentFlavor])
const handleChange = useCallback((e: ReactChangeEvent<HTMLTextAreaElement>) => {
const selection = {
@@ -324,9 +342,9 @@ export function HappyComposer(props: {
haptic('light')
}, [onModelModeChange, controlsDisabled, haptic])
const showPermissionSettings = Boolean(onPermissionModeChange)
const showModelSettings = Boolean(onModelModeChange)
const showSettingsButton = Boolean(onPermissionModeChange || onModelModeChange)
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModes.length > 0)
const showModelSettings = Boolean(onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini')
const showSettingsButton = Boolean(showPermissionSettings || showModelSettings)
const showAbortButton = true
const overlays = useMemo(() => {
@@ -339,7 +357,7 @@ export function HappyComposer(props: {
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
Permission Mode
</div>
{PERMISSION_MODES.map((mode) => (
{permissionModes.map((mode) => (
<button
key={mode}
type="button"
@@ -440,6 +458,7 @@ export function HappyComposer(props: {
controlsDisabled,
permissionMode,
modelMode,
permissionModes,
handlePermissionChange,
handleModelChange,
handleSuggestionSelect
@@ -458,6 +477,7 @@ export function HappyComposer(props: {
contextSize={contextSize}
modelMode={modelMode}
permissionMode={permissionMode}
agentFlavor={agentFlavor}
/>
<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
+12 -2
View File
@@ -6,7 +6,10 @@ const PERMISSION_MODE_LABELS: Record<string, string> = {
default: 'Default',
acceptEdits: 'Accept Edits',
plan: 'Plan Mode',
bypassPermissions: 'Bypass All'
bypassPermissions: 'Yolo',
'read-only': 'Read Only',
'safe-yolo': 'Safe Yolo',
yolo: 'Yolo'
}
// Vibing messages for thinking state
@@ -91,6 +94,7 @@ export function StatusBar(props: {
contextSize?: number
modelMode?: ModelMode
permissionMode?: PermissionMode
agentFlavor?: string | null
}) {
const connectionStatus = useMemo(
() => getConnectionStatus(props.active, props.thinking, props.agentState),
@@ -108,6 +112,9 @@ export function StatusBar(props: {
)
const permissionMode = props.permissionMode
const shouldShowPermissionMode = props.agentFlavor !== 'gemini'
&& permissionMode
&& permissionMode !== 'default'
return (
<div className="flex items-center justify-between px-2 pb-1">
@@ -127,11 +134,14 @@ export function StatusBar(props: {
) : null}
</div>
{(permissionMode && permissionMode !== 'default') ? (
{shouldShowPermissionMode ? (
<span className={`text-xs ${
permissionMode === 'acceptEdits' ? 'text-amber-500' :
permissionMode === 'bypassPermissions' ? 'text-red-500' :
permissionMode === 'plan' ? 'text-blue-500' :
permissionMode === 'read-only' ? 'text-amber-500' :
permissionMode === 'safe-yolo' ? 'text-amber-500' :
permissionMode === 'yolo' ? 'text-red-500' :
'text-[var(--app-hint)]'
}`}>
{PERMISSION_MODE_LABELS[permissionMode]}
+1
View File
@@ -177,6 +177,7 @@ export function SessionChat(props: {
disabled={props.isSending || controlsDisabled}
permissionMode={props.session.permissionMode}
modelMode={props.session.modelMode}
agentFlavor={props.session.metadata?.flavor ?? 'claude'}
active={props.session.active}
thinking={props.session.thinking}
agentState={props.session.agentState}
+2 -2
View File
@@ -3,11 +3,11 @@ import type { ApiClient } from '@/api/client'
import type { ModelMode, PermissionMode } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
type PermissionModeValue = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
type PermissionModeValue = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
type ModelModeValue = 'default' | 'sonnet' | 'opus'
function toPermissionMode(mode: PermissionMode): PermissionModeValue {
if (mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan') {
if (mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan' || mode === 'read-only' || mode === 'safe-yolo' || mode === 'yolo') {
return mode
}
return 'default'
+2 -2
View File
@@ -1,5 +1,5 @@
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null | undefined
export type ModelMode = 'default' | 'sonnet' | 'opus' | null | undefined
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
export type ModelMode = 'default' | 'sonnet' | 'opus'
export type WorktreeMetadata = {
basePath: string