mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 })
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user