typeduck-codex-web 0.5.1

A standalone browser interface and Codex runtime
Documentation
use serde_json::Value;
use topcoat::Result;
use topcoat::view::component;
use topcoat::view::view;

use crate::daemon_config::AccountProfile;

#[component]
pub(crate) async fn goal_modal() -> Result {
    view! {
        <div id="goal-modal" class="fixed inset-0 z-[70] hidden place-items-center bg-background/75 p-4 backdrop-blur-sm">
            <section role="dialog" aria-modal="true" aria-labelledby="goal-modal-title" class="w-full max-w-xl overflow-hidden rounded-2xl border border-border bg-background shadow-sm">
                <header class="flex items-start gap-3 border-b border-border px-5 py-4">
                    <div class="min-w-0 flex-1">
                        <h2 id="goal-modal-title" class="text-sm font-semibold">"Session goal"</h2>
                        <p class="mt-1 text-xs text-muted-foreground">"Keep a persistent objective, lifecycle state, and optional token budget for this conversation."</p>
                    </div>
                    <button type="button" data-close-modal="goal-modal" class="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/5 hover:text-foreground" aria-label="Close">"×"</button>
                </header>
                <form id="goal-form" class="grid gap-4 p-5">
                    <label class="grid gap-1.5 text-xs font-medium">
                        "Objective"
                        <textarea id="goal-objective" rows="4" required="" class="resize-y rounded-lg border border-border bg-background px-3 py-2 text-sm leading-6 outline-none focus-visible:ring-2 focus-visible:ring-ring" placeholder="What should Codex keep working toward?"></textarea>
                    </label>
                    <div class="grid grid-cols-2 gap-4 max-sm:grid-cols-1">
                        <label class="grid gap-1.5 text-xs font-medium">
                            "Status"
                            <select id="goal-status" class="h-10 rounded-lg border border-border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
                                <option value="active">"Active"</option>
                                <option value="paused">"Paused"</option>
                                <option value="blocked">"Blocked"</option>
                                <option value="usageLimited">"Usage limited"</option>
                                <option value="budgetLimited">"Budget limited"</option>
                                <option value="complete">"Complete"</option>
                            </select>
                        </label>
                        <label class="grid gap-1.5 text-xs font-medium">
                            "Token budget"
                            <input id="goal-token-budget" type="number" min="1" step="1" class="h-10 rounded-lg border border-border bg-background px-3 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" placeholder="Unlimited">
                        </label>
                    </div>
                    <div id="goal-usage" class="hidden grid-cols-2 gap-3 rounded-xl border border-border bg-foreground/[0.025] p-4 text-xs text-muted-foreground"></div>
                    <p id="goal-error" class="hidden text-xs text-destructive"></p>
                    <footer class="flex items-center gap-2 border-t border-border pt-4">
                        <button id="goal-clear" type="button" class="rounded-lg px-3 py-2 text-sm text-destructive hover:bg-destructive/10">"Clear goal"</button>
                        <button type="button" data-close-modal="goal-modal" class="ml-auto rounded-lg border border-border px-3 py-2 text-sm hover:bg-foreground/5">"Cancel"</button>
                        <button type="submit" class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground">"Save goal"</button>
                    </footer>
                </form>
            </section>
        </div>
    }
}

#[component]
pub(crate) async fn settings_modal(
    mcp_servers: &[Value],
    accounts: &[AccountProfile],
    active_account: &str,
) -> Result {
    view! {
        <div id="settings-modal" class="fixed inset-0 z-[70] hidden place-items-center bg-background/75 p-4 backdrop-blur-sm">
            <section role="dialog" aria-modal="true" aria-labelledby="settings-modal-title" class="flex max-h-[min(48rem,92vh)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-sm">
                <header class="flex items-start gap-3 border-b border-border px-5 py-4">
                    <div class="min-w-0 flex-1">
                        <h2 id="settings-modal-title" class="text-sm font-semibold">"Settings"</h2>
                        <p class="mt-1 text-xs text-muted-foreground">"Appearance, accounts, and MCP connections."</p>
                    </div>
                    <button type="button" data-close-modal="settings-modal" class="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/5 hover:text-foreground" aria-label="Close">"×"</button>
                </header>
                <div class="min-h-0 overflow-y-auto p-5">
                    <section>
                        <h3 class="text-sm font-semibold">"Appearance"</h3>
                        <label class="mt-3 flex items-center justify-between gap-4 rounded-xl border border-border p-4 text-sm">
                            <span><strong class="block font-medium">"Theme"</strong><span class="mt-1 block text-xs text-muted-foreground">"Dark is used by default, including while pages load."</span></span>
                            <select id="settings-theme" class="h-9 rounded-lg border border-border bg-background px-3 text-sm">
                                <option value="dark">"Dark"</option>
                                <option value="light">"Light"</option>
                                <option value="system">"System"</option>
                            </select>
                        </label>
                    </section>
                    <section class="mt-7">
                        <h3 class="text-sm font-semibold">"Accounts"</h3>
                        <p class="mt-1 text-xs leading-5 text-muted-foreground">"Profiles are tried from top to bottom when the daemon starts. Sign-in actions apply to the active profile."</p>
                        <div class="mt-3 divide-y divide-border overflow-hidden rounded-xl border border-border">
                            for account in accounts {
                                <div class="flex items-center gap-3 px-4 py-3">
                                    <span class="grid size-7 place-items-center rounded-lg bg-foreground/5 font-mono text-xs">"@"</span>
                                    <span class="min-w-0 flex-1"><strong class="block truncate text-sm font-medium">(account.label.as_str())</strong><span class="block truncate font-mono text-[0.66rem] text-muted-foreground">(account.codex_home.display().to_string())</span></span>
                                    if account.name == active_account {
                                        <span class="rounded-full bg-sky-500/10 px-2 py-1 text-[0.65rem] font-medium text-sky-400">"Active"</span>
                                    }
                                    <span class="flex items-center gap-1">
                                        <button type="button" data-account-move=(account.name.as_str()) data-direction="up" class="grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-foreground/5 hover:text-foreground" title="Move earlier">""</button>
                                        <button type="button" data-account-move=(account.name.as_str()) data-direction="down" class="grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-foreground/5 hover:text-foreground" title="Move later">""</button>
                                        if account.name != active_account {
                                            <button type="button" data-account-remove=(account.name.as_str()) class="grid size-7 place-items-center rounded-md text-destructive hover:bg-destructive/10" title="Remove profile">"×"</button>
                                        }
                                    </span>
                                </div>
                            }
                        </div>
                        <div class="mt-3 flex flex-wrap gap-2">
                            <button type="button" data-account-login="" class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground">"Sign in with ChatGPT"</button>
                            <button type="button" data-account-api-key="" class="rounded-lg border border-border px-3 py-2 text-sm hover:bg-foreground/5">"Use API key"</button>
                            <button type="button" data-account-logout="" class="rounded-lg px-3 py-2 text-sm text-destructive hover:bg-destructive/10">"Sign out"</button>
                        </div>
                        <p data-account-message="" class="mt-3 hidden text-xs text-muted-foreground"></p>
                        <details class="mt-4 rounded-xl border border-border p-4">
                            <summary class="cursor-pointer text-sm font-medium">"Add account profile"</summary>
                            <form data-account-profile-form="" class="mt-4 grid gap-3">
                                <div class="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
                                    <label class="grid gap-1 text-xs font-medium">"Name"<input name="name" required="" pattern="[A-Za-z0-9_-]+" class="h-9 rounded-lg border border-border bg-background px-3 font-mono text-sm" placeholder="personal"></label>
                                    <label class="grid gap-1 text-xs font-medium">"Label"<input name="label" class="h-9 rounded-lg border border-border bg-background px-3 text-sm" placeholder="Personal"></label>
                                </div>
                                <label class="grid gap-1 text-xs font-medium">"Codex home"<input name="codexHome" required="" class="h-9 rounded-lg border border-border bg-background px-3 font-mono text-sm" placeholder="/home/me/.codex-personal"></label>
                                <label class="grid gap-1 text-xs font-medium">"HTTP or SOCKS proxy (optional)"<input name="proxy" class="h-9 rounded-lg border border-border bg-background px-3 font-mono text-sm" placeholder="socks5h://127.0.0.1:1080"></label>
                                <label class="flex items-center gap-2 text-xs"><input name="useSshTunnel" type="checkbox">"Use the daemon's managed SSH tunnel"</label>
                                <button type="submit" class="w-fit rounded-lg border border-border px-3 py-2 text-sm font-medium hover:bg-foreground/5">"Add profile"</button>
                            </form>
                        </details>
                        <p class="mt-3 text-[0.68rem] leading-5 text-muted-foreground">"Profile order, additions, and removals are saved to daemon.toml and take effect after restarting the daemon."</p>
                    </section>
                    <section class="mt-7">
                        <div class="flex items-baseline gap-2"><h3 class="text-sm font-semibold">"MCP servers"</h3><span class="font-mono text-[0.66rem] text-muted-foreground">(mcp_servers.len())" configured"</span></div>
                        <div class="mt-3 divide-y divide-border overflow-hidden rounded-xl border border-border">
                            if mcp_servers.is_empty() {
                                <p class="px-4 py-5 text-sm text-muted-foreground">"No MCP servers are configured."</p>
                            }
                            for server in mcp_servers {
                                <div class="flex items-center gap-3 px-4 py-3">
                                    <span class=(if needs_login(server) { "size-2 rounded-full bg-amber-500" } else { "size-2 rounded-full bg-emerald-500" })></span>
                                    <span class="min-w-0 flex-1"><strong class="block truncate text-sm font-medium">(field(server, "name"))</strong><span class="block text-xs text-muted-foreground">(if needs_login(server) { "Authentication required" } else { "Connected" })</span></span>
                                    if needs_login(server) {
                                        <button type="button" data-mcp-login=(field(server, "name")) class="rounded-lg border border-border px-3 py-1.5 text-xs hover:bg-foreground/5">"Log in"</button>
                                    }
                                </div>
                            }
                        </div>
                    </section>
                </div>
            </section>
        </div>
    }
}

fn field<'a>(value: &'a Value, name: &str) -> &'a str {
    value.get(name).and_then(Value::as_str).unwrap_or_default()
}

fn needs_login(server: &Value) -> bool {
    matches!(field(server, "authStatus"), "notAuthenticated" | "expired")
}

#[cfg(test)]
#[path = "panels_tests.rs"]
mod tests;