Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/happy-agent/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe('happy-agent CLI', () => {
expect(stdout).toContain('--machine');
expect(stdout).toContain('--path');
expect(stdout).toContain('--agent');
expect(stdout).toContain('opencode');
expect(stdout).toContain('--json');
});

Expand Down
2 changes: 1 addition & 1 deletion packages/happy-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { formatMachineTable, formatSessionTable, formatSessionStatus, formatMess

// --- Helpers ---

const SUPPORTED_AGENTS: SupportedAgent[] = ['claude', 'codex', 'gemini', 'openclaw'];
const SUPPORTED_AGENTS: SupportedAgent[] = ['claude', 'codex', 'gemini', 'openclaw', 'opencode'];

function resolveByPrefix<T extends { id: string }>(items: T[], value: string, label: string): T {
if (!value || value.trim().length === 0) {
Expand Down
2 changes: 1 addition & 1 deletion packages/happy-agent/src/machineRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Config } from './config';
import type { DecryptedMachine } from './api';
import { decodeBase64, encodeBase64, encrypt, decrypt } from './encryption';

export type SupportedAgent = 'claude' | 'codex' | 'gemini' | 'openclaw';
export type SupportedAgent = 'claude' | 'codex' | 'gemini' | 'openclaw' | 'opencode';

export type SpawnMachineSessionResult =
| { type: 'success'; sessionId: string }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,16 @@ const agentIcons = {
codex: require('@/assets/images/icon-gpt.png'),
openclaw: require('@/assets/images/icon-openclaw.png'),
gemini: require('@/assets/images/icon-gemini.png'),
opencode: require('@/assets/images/icon-opencode.png'),
};

type AgentKey = 'claude' | 'codex' | 'openclaw' | 'gemini';
type AgentKey = 'claude' | 'codex' | 'openclaw' | 'gemini' | 'opencode';
const AGENTS: { key: AgentKey; label: string }[] = [
{ key: 'claude', label: 'claude code' },
{ key: 'codex', label: 'codex' },
{ key: 'openclaw', label: 'openclaw' },
{ key: 'gemini', label: 'gemini' },
{ key: 'opencode', label: 'opencode' },
];

// Sample data for pickers
Expand Down
60 changes: 24 additions & 36 deletions packages/happy-app/sources/app/(app)/machine/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ import { useNavigateToSession } from '@/hooks/useNavigateToSession';
import { machineSpawnNewSession } from '@/sync/ops';
import { resolveAbsolutePath } from '@/utils/pathUtils';
import { MultiTextInput, type MultiTextInputHandle } from '@/components/MultiTextInput';
import { ALL_AGENTS } from '@/components/newSessionAgents';

const CLI_AVAILABILITY_LABELS = {
claude: 'Claude',
codex: 'Codex',
openclaw: 'OpenClaw',
gemini: 'Gemini',
opencode: 'OpenCode',
} as const;

const styles = StyleSheet.create((theme) => ({
pathInputContainer: {
Expand Down Expand Up @@ -518,42 +527,21 @@ export default function MachineDetailScreen() {
{/* CLI Availability */}
{metadata?.cliAvailability && (
<ItemGroup title={t('machine.cliAvailability')}>
<Item
title="Claude"
showChevron={false}
rightElement={
<Text style={{ color: metadata.cliAvailability.claude ? '#34C759' : theme.colors.textSecondary, fontSize: 14 }}>
{metadata.cliAvailability.claude ? t('machine.cliInstalled') : t('machine.cliNotFound')}
</Text>
}
/>
<Item
title="Codex"
showChevron={false}
rightElement={
<Text style={{ color: metadata.cliAvailability.codex ? '#34C759' : theme.colors.textSecondary, fontSize: 14 }}>
{metadata.cliAvailability.codex ? t('machine.cliInstalled') : t('machine.cliNotFound')}
</Text>
}
/>
<Item
title="Gemini"
showChevron={false}
rightElement={
<Text style={{ color: metadata.cliAvailability.gemini ? '#34C759' : theme.colors.textSecondary, fontSize: 14 }}>
{metadata.cliAvailability.gemini ? t('machine.cliInstalled') : t('machine.cliNotFound')}
</Text>
}
/>
<Item
title="OpenClaw"
showChevron={false}
rightElement={
<Text style={{ color: metadata.cliAvailability.openclaw ? '#34C759' : theme.colors.textSecondary, fontSize: 14 }}>
{metadata.cliAvailability.openclaw ? t('machine.cliInstalled') : t('machine.cliNotFound')}
</Text>
}
/>
{ALL_AGENTS.map((agent) => {
const isInstalled = metadata.cliAvailability?.[agent.key] === true;
return (
<Item
key={agent.key}
title={CLI_AVAILABILITY_LABELS[agent.key]}
showChevron={false}
rightElement={
<Text style={{ color: isInstalled ? '#34C759' : theme.colors.textSecondary, fontSize: 14 }}>
{isInstalled ? t('machine.cliInstalled') : t('machine.cliNotFound')}
</Text>
}
/>
);
})}
<Item
title={t('machine.lastDetected')}
subtitle={new Date(metadata.cliAvailability.detectedAt).toLocaleString()}
Expand Down
38 changes: 23 additions & 15 deletions packages/happy-app/sources/app/(app)/new/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { Modal } from '@/modal';
import type { Machine, Session } from '@/sync/storageTypes';
import {
getHardcodedPermissionModes,
getHardcodedModelModes,
getAvailableModels,
getEffortLevelsForModel,
getDefaultEffortKeyForModel,
getDefaultPermissionModeKey,
Expand All @@ -56,23 +56,17 @@ import {
type EffortLevel,
} from '@/components/modelModeOptions';
import { isRunningOnMac } from '@/utils/platform';
import { ALL_AGENTS, getAvailableNewSessionAgents, type AgentKey } from '@/components/newSessionAgents';

// Agent icon assets
const agentIcons = {
claude: require('@/assets/images/icon-claude.png'),
codex: require('@/assets/images/icon-gpt.png'),
openclaw: require('@/assets/images/icon-openclaw.png'),
gemini: require('@/assets/images/icon-gemini.png'),
opencode: require('@/assets/images/icon-opencode.png'),
};

type AgentKey = NewSessionAgentType;
const ALL_AGENTS: { key: AgentKey; label: string }[] = [
{ key: 'claude', label: 'claude code' },
{ key: 'codex', label: 'codex' },
{ key: 'openclaw', label: 'openclaw' },
{ key: 'gemini', label: 'gemini' },
];

type PickerItem = { key: string; label: string; subtitle?: string; dimmed?: boolean };

type PickerType = 'machine' | 'path' | 'worktree';
Expand Down Expand Up @@ -616,9 +610,7 @@ function NewSessionScreen() {

// Filter available agents based on CLI availability from machine metadata
const availableAgents = React.useMemo(() => {
const availability = selectedMachine?.metadata?.cliAvailability;
if (!availability) return ALL_AGENTS;
return ALL_AGENTS.filter(a => availability[a.key]);
return getAvailableNewSessionAgents(selectedMachine?.metadata?.cliAvailability);
}, [selectedMachine]);

// If current agent not available on this machine, switch to first available
Expand All @@ -628,14 +620,30 @@ function NewSessionScreen() {
}
}, [availableAgents, selectedAgent, setSelectedAgent]);

const recentAgentMetadata = React.useMemo(() => {
if (!selectedMachineId || !sessions) return null;
let latest: Session | null = null;
for (const s of sessions) {
if (typeof s === 'string') continue;
const session = s as Session;
if (session.metadata?.machineId !== selectedMachineId) continue;
if (session.metadata?.flavor !== selectedAgent) continue;
if (!session.metadata?.models || session.metadata.models.length === 0) continue;
if (!latest || session.activeAt > latest.activeAt) {
latest = session;
}
}
return latest?.metadata ?? null;
}, [selectedAgent, selectedMachineId, sessions]);

// Derive options from agent type
const permissionModes = React.useMemo<PermissionMode[]>(
() => getHardcodedPermissionModes(selectedAgent, t),
[selectedAgent],
);
const modelModes = React.useMemo<ModelMode[]>(
() => getHardcodedModelModes(selectedAgent, t),
[selectedAgent],
() => getAvailableModels(selectedAgent, recentAgentMetadata, t),
[recentAgentMetadata, selectedAgent],
);

const currentModel = modelModes[modelIndex] ?? modelModes[0];
Expand All @@ -647,7 +655,7 @@ function NewSessionScreen() {
);

const supportsWorktree = getSupportsWorktree(selectedAgent);
const showModel = modelModes.length > 1;
const showModel = selectedAgent === 'opencode' || modelModes.length > 1;
const showEffort = effortLevels.length > 0;
const showPermission = permissionModes.length > 1;

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/app/(app)/session/[id]/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ function SessionInfoContent({ session }: { session: Session }) {
if (flavor === 'gpt' || flavor === 'openai') return 'Codex';
if (flavor === 'gemini') return 'Gemini';
if (flavor === 'openclaw') return 'OpenClaw';
if (flavor === 'opencode') return 'OpenCode';
return flavor;
})()}
icon={<Ionicons name="sparkles-outline" size={29} color="#5856D6" />}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions packages/happy-app/sources/components/AgentInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ interface AgentInputProps {
};
alwaysShowContextSize?: boolean;
onFileViewerPress?: () => void;
agentType?: 'claude' | 'codex' | 'gemini' | 'openclaw';
agentType?: 'claude' | 'codex' | 'gemini' | 'openclaw' | 'opencode';
onAgentClick?: () => void;
machineName?: string | null;
onMachineClick?: () => void;
Expand Down Expand Up @@ -1135,7 +1135,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen
fontWeight: '600',
...Typography.default('semiBold'),
}}>
{props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : props.agentType === 'openclaw' ? t('agentInput.agent.openclaw') : t('agentInput.agent.gemini')}
{props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : props.agentType === 'openclaw' ? t('agentInput.agent.openclaw') : props.agentType === 'opencode' ? t('agentInput.agent.opencode') : t('agentInput.agent.gemini')}
</Text>
</Pressable>
)}
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const flavorIcons = {
codex: require('@/assets/images/icon-gpt.png'),
gemini: require('@/assets/images/icon-gemini.png'),
openclaw: require('@/assets/images/icon-openclaw.png'),
opencode: require('@/assets/images/icon-opencode.png'),
};

const styles = StyleSheet.create((theme) => ({
Expand Down
32 changes: 30 additions & 2 deletions packages/happy-app/sources/components/modelModeOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
getAvailablePermissionModes,
getCodexModelModes,
getClaudePermissionModes,
getOpenCodeModelModes,
getOpenCodePermissionModes,
mapMetadataOptions,
resolveCurrentOption,
} from './modelModeOptions';
Expand All @@ -23,14 +25,27 @@ describe('modelModeOptions', () => {

it('builds claude permission fallbacks with translated names', () => {
const modes = getClaudePermissionModes(translate);
expect(modes.map((mode) => mode.key)).toEqual(['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions']);
expect(modes.map((mode) => mode.key)).toEqual(['default', 'plan', 'dontAsk', 'acceptEdits', 'bypassPermissions']);
expect(modes[0].name).toBe('tr:agentInput.permissionMode.default');
});

it('builds opencode permission fallbacks with translated names', () => {
const modes = getOpenCodePermissionModes(translate);
expect(modes.map((mode) => mode.key)).toEqual(['default']);
expect(modes[0].name).toBe('tr:agentInput.permissionMode.default');
});

it('builds opencode model fallbacks', () => {
const models = getOpenCodeModelModes();
expect(models.map((model) => model.key)).toEqual(['default']);
expect(models[0].name).toBe('default model');
});

it('builds codex model fallbacks', () => {
const models = getCodexModelModes();
expect(models.map((model) => model.key)).toEqual([
'default',
'gpt-5.5',
'gpt-5.4',
'gpt-5.3-codex',
'gpt-5.2-codex',
Expand All @@ -39,7 +54,7 @@ describe('modelModeOptions', () => {
'gpt-5.1-codex-mini',
]);
expect(models[0].name).toBe('default model');
expect(models[1].name).toBe('gpt-5.4');
expect(models[1].name).toBe('gpt-5.5');
});

it('prefers metadata models over hardcoded fallbacks', () => {
Expand Down Expand Up @@ -67,6 +82,19 @@ describe('modelModeOptions', () => {
]);
});

it('adds opencode default model option when metadata models are present', () => {
const models = getAvailableModels('opencode', {
models: [
{ code: 'openai/gpt-5.5', value: 'GPT 5.5', description: 'From OpenCode' },
],
} as any, translate);

expect(models).toEqual([
{ key: 'default', name: 'default model', description: null },
{ key: 'openai/gpt-5.5', name: 'GPT 5.5', description: 'From OpenCode' },
]);
});

it('keeps codex permission modes hardcoded even when metadata modes exist', () => {
const modes = getAvailablePermissionModes('codex', {
operatingModes: [{ code: 'metadata-only', value: 'Metadata Mode', description: null }],
Expand Down
26 changes: 24 additions & 2 deletions packages/happy-app/sources/components/modelModeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ export function getGeminiModelModes(): ModelMode[] {
return GEMINI_MODEL_FALLBACKS;
}

export function getOpenCodePermissionModes(translate: Translate): PermissionMode[] {
return [
{ key: 'default', name: translate('agentInput.permissionMode.default'), description: null },
];
}

export function getOpenCodeModelModes(): ModelMode[] {
return [
{ key: 'default', name: 'default model', description: null },
];
}

export function getOpenClawPermissionModes(translate: Translate): PermissionMode[] {
return [
{ key: 'default', name: translate('agentInput.permissionMode.default'), description: null },
Expand All @@ -107,6 +119,9 @@ export function getOpenClawPermissionModes(translate: Translate): PermissionMode
}

export function getHardcodedPermissionModes(flavor: AgentFlavor, translate: Translate): PermissionMode[] {
if (flavor === 'opencode') {
return getOpenCodePermissionModes(translate);
}
if (flavor === 'codex') {
return getCodexPermissionModes(translate);
}
Expand All @@ -126,6 +141,9 @@ export function getOpenClawModelModes(): ModelMode[] {
}

export function getHardcodedModelModes(flavor: AgentFlavor, _translate: Translate): ModelMode[] {
if (flavor === 'opencode') {
return getOpenCodeModelModes();
}
if (flavor === 'codex') {
return getCodexModelModes();
}
Expand All @@ -145,7 +163,7 @@ export function getAvailableModels(
): ModelMode[] {
const metadataModels = mapMetadataOptions(metadata?.models);
if (metadataModels.length > 0) {
if (flavor === 'codex' && !metadataModels.some((model) => model.key === 'default')) {
if ((flavor === 'codex' || flavor === 'opencode') && !metadataModels.some((model) => model.key === 'default')) {
return [{ key: 'default', name: 'default model', description: null }, ...metadataModels];
}
return metadataModels;
Expand Down Expand Up @@ -225,17 +243,18 @@ export function getCodexEffortLevels(): EffortLevel[] {
}

export function getHardcodedEffortLevels(flavor: AgentFlavor): EffortLevel[] {
if (flavor === 'opencode') return [];
if (flavor === 'claude') return getClaudeEffortLevels();
if (flavor === 'codex') return getCodexEffortLevels();
return [];
}

export function getDefaultEffortKey(flavor: AgentFlavor): string | null {
if (flavor === 'opencode') return null;
if (flavor === 'claude' || flavor === 'codex') return 'high';
return null;
}

// Per-model effort: returns effort levels for a specific model, or empty if the model has no effort
export function getEffortLevelsForModel(flavor: AgentFlavor, modelKey: string): EffortLevel[] {
if (flavor === 'claude') {
if (modelKey === 'default') return [];
Expand All @@ -244,6 +263,9 @@ export function getEffortLevelsForModel(flavor: AgentFlavor, modelKey: string):
if (flavor === 'codex') {
return getCodexEffortLevels();
}
if (flavor === 'opencode') {
return [];
}
return [];
}

Expand Down
Loading