Skip to content
Closed
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).

Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Oh My Pi. If they're set up on your computer, T3 Code can control them.

## "Wait, what are you selling me?"

Expand All @@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Oh My Pi. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
> - Oh My Pi: install [Oh My Pi](https://omp.sh) and run `omp setup`

### Try it out (install-free)

Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Third-Party Notices

## Oh My Pi

The Oh My Pi provider icon in `src/components/ProviderIcon.tsx` is adapted
from the [`oh-my-pi`](https://github.com/can1357/oh-my-pi) project.

Copyright (c) 2025 Mario Zechner

Copyright (c) 2025-2026 Can Bölük

Copyright (c) 2026 Stencil Labs, Inc.

Licensed under the MIT License. The full license text is available in the
upstream repository: <https://github.com/can1357/oh-my-pi/blob/main/LICENSE>.
18 changes: 17 additions & 1 deletion apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Path, Svg } from "react-native-svg";
import { Circle, Path, Rect, Svg } from "react-native-svg";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";

type ProviderIconProps = {
Expand Down Expand Up @@ -59,6 +59,22 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "omp") {
const fill = isDarkMode ? "#FAFAFA" : "#211E1E";
return (
<Svg width={size} height={size} viewBox="0 0 120 90" fill="none">
<Rect x="10" y="8" width="100" height="12" rx="2" fill={fill} />
<Rect x="25" y="20" width="12" height="62" rx="2" fill={fill} />
<Rect x="75" y="20" width="12" height="45" rx="2" fill={fill} />
<Rect x="71" y="55" width="20" height="16" rx="3" fill="#F97316" />
<Rect x="75" y="59" width="4" height="8" rx="1" fill="#211E1E" />
<Rect x="83" y="59" width="4" height="8" rx="1" fill="#211E1E" />
<Circle cx="18" cy="14" r="2" fill="#F97316" opacity={0.8} />
<Circle cx="102" cy="14" r="2" fill="#F97316" opacity={0.8} />
</Svg>
);
}

// codex (and unknown drivers)
return (
<Svg width={size} height={size} viewBox="0 0 256 260" fill="none">
Expand Down
19 changes: 15 additions & 4 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ function ModelRow(props: {
const checkmarkColor = useThemeColor("--color-icon");
return (
<Pressable
accessibilityLabel={props.option.label}
accessibilityLabel={
props.option.subProvider
? `${props.option.label}. ${props.option.subProvider}`
: props.option.label
}
accessibilityRole="radio"
accessibilityState={{ checked: props.selected }}
onPress={props.onPress}
Expand All @@ -104,9 +108,16 @@ function ModelRow(props: {
props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",
)}
>
<Text className="min-w-0 shrink text-base font-t3-medium text-foreground" numberOfLines={1}>
{props.option.label}
</Text>
<View className="min-w-0 shrink gap-0.5">
<Text className="text-base font-t3-medium text-foreground" numberOfLines={1}>
{props.option.label}
</Text>
{props.option.subProvider ? (
<Text className="text-xs text-foreground-muted" numberOfLines={1}>
{props.option.subProvider}
</Text>
) : null}
</View>
{props.option.isDefault ? (
<View className="rounded-md bg-subtle-strong px-1.5 py-0.5">
<Text className="text-3xs font-t3-bold text-foreground-muted">Default</Text>
Expand Down
13 changes: 9 additions & 4 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
groupByProvider,
resolveDefaultableModelSelection,
resolveSelectableModelSelection,
selectedProviderShowsInteractionModeToggle,
} from "../../lib/modelOptions";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { appAtomRegistry } from "../../state/atom-registry";
Expand Down Expand Up @@ -194,7 +195,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const threads = useThreadShells();
const { savedConnectionsById } = useSavedRemoteConnections();
const groupingSettings = useMobileProjectGroupingSettings();
const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState();
const { enabled: planModePreferenceEnabled, loaded: planModePreferenceLoaded } =
useLegacyPlanModeState();
const projectScopes = useMemo(
() =>
sortHomeProjectScopes({
Expand Down Expand Up @@ -401,9 +403,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
true;
const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode = planModeEnabled
? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
: DEFAULT_PROVIDER_INTERACTION_MODE;

// Stored selections only count while their provider is usable on the
// server; otherwise the server's default model wins instead of silently
Expand Down Expand Up @@ -433,6 +432,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
modelOptions.find((option) => option.isDefault)?.selection ??
modelOptions[0]?.selection ??
null;
const planModeEnabled =
planModePreferenceEnabled &&
selectedProviderShowsInteractionModeToggle(selectedEnvironmentServerConfig, selectedModel);
const interactionMode = planModeEnabled
? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
: DEFAULT_PROVIDER_INTERACTION_MODE;
const selectedModelKey = selectedModel
? `${selectedModel.instanceId}:${selectedModel.model}`
: null;
Expand Down
62 changes: 62 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,43 @@ import {
groupByProvider,
resolveDefaultableModelSelection,
resolveSelectableModelSelection,
selectedProviderShowsInteractionModeToggle,
} from "./modelOptions";

describe("mobile model options", () => {
it("preserves the upstream provider for OMP model rows", () => {
const config = {
providers: [
{
instanceId: "omp",
driver: "omp",
displayName: "Oh My Pi",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "moonshot/kimi-k2.6",
name: "Kimi K2.6",
subProvider: "Moonshot",
isCustom: false,
capabilities: null,
},
],
},
],
} as unknown as ServerConfig;

expect(buildModelOptions(config, null)).toMatchObject([
{
label: "Kimi K2.6",
providerLabel: "Oh My Pi",
subProvider: "Moonshot",
subtitle: "Oh My Pi · Moonshot",
},
]);
});

it("groups models by provider and flags legacy entries", () => {
const config = {
providers: [
Expand Down Expand Up @@ -171,4 +205,32 @@ describe("mobile model options", () => {
// Offline: nothing to validate against, selection passes through.
expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);
});

it("uses the selected provider interaction-mode capability", () => {
const config = {
providers: [
{
instanceId: "omp",
driver: "omp",
enabled: true,
installed: true,
auth: { status: "authenticated" },
showInteractionModeToggle: false,
models: [],
},
],
} as unknown as ServerConfig;
const ompSelection = {
instanceId: ProviderInstanceId.make("omp"),
model: "moonshot/kimi-k2.6",
};
const missingSelection = {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5.6-sol",
};

expect(selectedProviderShowsInteractionModeToggle(config, ompSelection)).toBe(false);
expect(selectedProviderShowsInteractionModeToggle(config, missingSelection)).toBe(true);
expect(selectedProviderShowsInteractionModeToggle(null, ompSelection)).toBe(true);
});
});
24 changes: 23 additions & 1 deletion apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type ModelOption = {
readonly subtitle: string;
readonly providerKey: string;
readonly providerLabel: string;
readonly subProvider?: string;
readonly providerDriver: string;
readonly isDefault: boolean;
readonly isLegacy: boolean;
Expand All @@ -35,6 +36,7 @@ function providerDisplayLabel(provider: {
if (provider.displayName) return provider.displayName;
if (provider.driver === "codex") return "Codex";
if (provider.driver === "claudeAgent") return "Claude";
if (provider.driver === "omp") return "Oh My Pi";
return provider.instanceId;
}

Expand Down Expand Up @@ -104,6 +106,24 @@ export function resolveDefaultableModelSelection(
return model?.isLegacy === true ? null : usable;
}

/**
* The server owns whether a provider supports interaction-mode selection. If
* the environment is offline or the selected provider is not in its latest
* snapshot, preserve the existing control until the server can describe it.
*/
export function selectedProviderShowsInteractionModeToggle(
config: T3ServerConfig | null | undefined,
selection: ModelSelection | null,
): boolean {
if (!config || !selection) {
return true;
}
return (
config.providers.find((provider) => provider.instanceId === selection.instanceId)
?.showInteractionModeToggle ?? true
);
}

export function buildModelOptions(
config: T3ServerConfig | null | undefined,
fallbackModelSelection: ModelSelection | null,
Expand All @@ -118,12 +138,14 @@ export function buildModelOptions(
const providerLabel = providerDisplayLabel(provider);
for (const model of provider.models) {
const key = `${provider.instanceId}:${model.slug}`;
const subProvider = model.subProvider?.trim();
options.set(key, {
key,
label: model.name,
subtitle: providerLabel,
subtitle: subProvider ? `${providerLabel} · ${subProvider}` : providerLabel,
providerKey: provider.instanceId,
providerLabel,
...(subProvider ? { subProvider } : {}),
providerDriver: provider.driver,
isDefault: model.isDefault === true,
isLegacy: model.isLegacy === true,
Expand Down
14 changes: 9 additions & 5 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type * as AcpSchema from "effect-acp/schema";
const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
const emitEditPermission = process.env.T3_ACP_EMIT_EDIT_PERMISSION === "1";
const emitInterleavedAssistantToolCalls =
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
Expand Down Expand Up @@ -660,15 +661,18 @@ const program = Effect.gen(function* () {
sessionId: requestedSessionId,
toolCall: {
toolCallId,
title: "`cat server/package.json`",
kind: "execute",
title: emitEditPermission ? "Delete obsolete file" : "`cat server/package.json`",
...(emitEditPermission ? {} : { kind: "execute" as const }),
status: "pending",
...(emitEditPermission ? { locations: [{ path: "/tmp/obsolete.ts" }] } : {}),
content: [
{
type: "content",
content: {
type: "text",
text: "Not in allowlist: cat server/package.json",
text: emitEditPermission
? "Delete obsolete file"
: "Not in allowlist: cat server/package.json",
},
},
],
Expand All @@ -693,8 +697,8 @@ const program = Effect.gen(function* () {
update: {
sessionUpdate: "tool_call_update",
toolCallId,
title: "Terminal",
kind: "execute",
title: emitEditPermission ? "Delete obsolete file" : "Terminal",
...(emitEditPermission ? {} : { kind: "execute" as const }),
status: "completed",
rawOutput: {
exitCode: 0,
Expand Down
Loading
Loading