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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,36 @@ jobs:

- name: Build the CLI
run: pnpm build

desktop:
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-go@v5
with:
go-version-file: desktop/go.mod
cache-dependency-path: desktop/go.sum
- run: pnpm install --frozen-lockfile

# The engine the app ships: the dispatcher, a runner and the API, in one file.
- name: Build the engine
run: pnpm build:engine

- name: Vet the app
working-directory: desktop
run: go vet ./... && go tool mygo vet .

# Draws every screen without a window, and loads them from a real engine.
- name: Test the app
working-directory: desktop
run: CGO_ENABLED=0 go test ./...

- name: Build the app
working-directory: desktop
run: go build ./...
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ config/oauth-app.json

# Trying hosted mode locally (docs/hosting.md)
.hosted/

# The desktop app's engine, built by scripts/build-engine.mjs (docs/desktop.md)
desktop/resources/engine/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Work comes in from GitHub, Slack, Lark, email, or the clock. A loop says what to
- Tailwind CSS with shadcn/ui
- SQLite for state (libSQL when hosted), encrypted secrets when hosted, the macOS keychain (or a file elsewhere) in `pnpm dev`
- Hosted on Cloudflare: the app as a Worker, the dispatcher as a Container; see `docs/hosting.md`
- A desktop app in `desktop/`, native UI in Go with MyGo, running the same engine (dispatcher, runner, API) as a Node process beside it; see `docs/desktop.md`

## Workspaces

Expand Down
7 changes: 6 additions & 1 deletion config/oauth-app.example.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"_comment": "Copy to config/oauth-app.json (gitignored) to sign in and connect while developing. Register each app with the loopback callback: GitHub http://127.0.0.1:4321/api/connectors/github/callback, Google http://127.0.0.1:4321/api/connectors/gmail/callback.",
"github": { "clientId": "", "clientSecret": "" },
"gmail": { "clientId": "", "clientSecret": "" }
"gmail": { "clientId": "", "clientSecret": "" },
"desktop": {
"_comment": "Built into the desktop app by scripts/build-engine.mjs. GitHub: an OAuth app with device flow turned on, ID only. Google: a client of type Desktop app.",
"github": { "clientId": "" },
"gmail": { "clientId": "", "clientSecret": "" }
}
}
37 changes: 37 additions & 0 deletions desktop/.agents/skills/mygo-maintenance/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
name: mygo-maintenance
description: Maintain Go applications using github.com/egoist/mygo, including UI changes, crash fixes, and dependency upgrades. Apply MyGo-specific lifetime, identity, threading, and testing practices.
---

# MyGo app maintenance

Check the app's pinned MyGo version and existing conventions before changing APIs. Locate its source with `go list -m -f '{{.Dir}}' github.com/egoist/mygo`; read `ui/doc.go` and relevant implementation comments. Read `docs/ui/migration.md` only for major-version upgrades, including minor-version bumps while on `0.x` (for example, `0.2.x` to `0.3.x`, or `1.x` to `2.x`). Confirm API availability against the pinned source.

The native UI rules below apply where the app uses package `ui`; preserve a web app's frontend and typed Go/TypeScript bindings.

## UI lifetime and identity

- Views receive a stable `*ui.Context` for their window. `Children(func())` shares it and changes its parent scope during the callback. Keep Context out of app state and workers; capture `c.Services()` for persistent clipboard/URL callbacks and background redraws.
- Constructors return checked `ui.Element` values. Elements and custom parts expire before the next build pass, including another pass in the same frame. Store app data, widget state and `ui.Handle`, never an element or parts. Zero elements are safe and `Valid()` is a quiet check; stale operations panic in development/Tester and become empty results or no-ops in production. Fix the ownership rather than relying on validity guards or recovery.
- Bind persistent identity with `.Bind(&handle)`. `handle.Focused(c)` and `FocusWithin(c)` read committed focus independently of construction order. `handle.Focus(c)` requests focus and waits while hidden; `CancelFocus(c)` cancels it. Handles support several windows; select one with its Context, and keep widget state separate per window.
- Use `.FocusBind(&a.pane, files)` when app data should request focus. The field holds desired focus; `ui.FocusedValue(c, &a.pane)` reads actual focus while a hidden target waits. Use a comparable field, unique target values and its zero value for no focus.
- Use `handle.Bounds(c)` for committed geometry in input callbacks. `c.Resolve(handle)` returns only the current build's element. Do not capture an element in a later input callback to query its bounds.
- Give controls stable, unique identity before construction with `c.Key(key)`, including stateful widgets. Container `.Key(key)` must precede children and local state. Configure fluent lists before `.Rows`; use `.ItemKey` for stable item identity and `ui.ListRow.Selected()`/`ListFocused()` for row styling.

## Input and actions

- Bound values update after construction and configuration. `Changed`/`Submitted` notices appear in the following build pass. Bind directly to persistent model fields when possible. A local value recomputed each pass loses the edit before its notice; write derived checkbox or segmented values back in `OnClick`, after that build's bound input updates them.
- `OnClick`/`OnShortcut` run after construction and bound input; `OnChange`/`OnSubmit` observe the rebuilt view's notices. Prefer deferred actions when changing a collection being built. `Clicked()` and shortcut queries remain supported for local interactions.
- Declare scoped commands with `handle.OnShortcut(c, mods, key, fn)`, before or after Bind. Only an enabled control present in the current build takes its command. Use `c.OnShortcut` for commands in the current parent scope.

## State, work, and rendering

- Views may rebuild several times for one event. Trigger external side effects through handled actions or explicit guarded jobs, not unconditional rendering code.
- Keep slow I/O and expensive computation off the UI thread. Publish results through `Window.Update`; it is asynchronous. `Invalidate` requests redraws without synchronizing model writes. Cancel obsolete jobs or reject stale results after source changes or window closure.
- Keep drawing callbacks limited to painting; they can run repeatedly. Prefer MyGo widgets/base widgets to preserve keyboard, focus, disabled, and accessibility behavior. Follow the app's theme and sizing conventions.

## Verification

- Reproduce UI failures with `ui.NewTester`. Exercise extra frames while elements disappear and return, plus relevant focus, typing, and shortcut behavior.
- Run targeted regression tests, `go tool mygo vet .` for stored build values and goroutine captures, and `CGO_ENABLED=0 go test ./...`. Use race checks when changing shared state, and inspect rendered output or the native app when changing layout/platform behavior, respecting the user's verification preferences.
- For upgrades, preview with `go tool mygo migrate-ui .`, apply with `-write`, and review persistent identity/services, separately assigned keys and derived bound values manually. Verify affected API/lifecycle assumptions against the new pinned source and rerun relevant UI tests. Report remaining platform-specific verification limits.
3 changes: 3 additions & 0 deletions desktop/.agents/skills/mygo-maintenance/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
interface:
display_name: "MyGo Maintenance"
short_description: "Maintain MyGo apps with safe UI lifetimes"
4 changes: 4 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/build/
/.mygo/
mygo_windows_*.syso
/desktop
197 changes: 197 additions & 0 deletions desktop/agents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package main

import (
"fmt"
"strconv"
"strings"

"github.com/egoist/mygo/ui"
)

var modeLabels = map[string]string{
"read_only": "Read only",
"workspace_write": "Can write in the workspace",
}

type agentEdit struct {
open bool
mode string
model string
seconds string
testOutput string
from string // settings this was made from, to start over on a change elsewhere
}

func (a *app) agentsPage(c *ui.Context) {
t := c.Theme()
a.header(c, "", "", "Agents", "The coding CLIs on this Mac. Loops run with one of them.", "", nil)
if a.m == nil {
ui.Spinner(c)
return
}
if len(a.agents) == 0 {
ui.Spinner(c)
return
}
card(c).Dividers(1, t.Border).Children(func() {
for _, manifest := range a.m.Agents {
for i := range a.agents {
if a.agents[i].AgentID == manifest.ID {
a.agentRow(c, &manifest, &a.agents[i])
}
}
}
})
}

func presence(view *AgentView) string {
if view.Auth != nil && view.Auth.SignedIn {
if view.Auth.Detail != "" {
return "Signed in · " + view.Auth.Detail
}
return "Signed in"
}
if view.Installed {
if view.Auth != nil && view.Auth.Detail != "" {
return "Installed, not signed in · " + view.Auth.Detail
}
return "Installed, not signed in"
}
return "Not installed"
}

func (a *app) agentRow(c *ui.Context, manifest *AgentManifest, view *AgentView) {
t := c.Theme()
edit := a.agentEdit[manifest.ID]
signature := fmt.Sprintf("%s|%s|%v", view.Settings.PermissionMode, deref(view.Settings.Model), view.Settings.TimeoutMs)
if edit == nil {
edit = &agentEdit{open: view.IsDefault && !view.DefaultIsImplicit}
a.agentEdit[manifest.ID] = edit
}
if edit.from != signature {
edit.from = signature
edit.mode = modeLabels[view.Settings.PermissionMode]
edit.model = deref(view.Settings.Model)
edit.seconds = itoa(int(view.Settings.TimeoutMs/1000 + 0.5))
}
online := view.Auth != nil && view.Auth.SignedIn
busy := a.busy["agent:"+manifest.ID]

ui.Column(c).Key(manifest.ID).Children(func() {
ui.Row(c).Gap(12).AlignItems(ui.Center).Padding(12, 16).Children(func() {
connectorTile(c, manifest.ID, 32, !online)
head := ui.ButtonBase(c).Grow(1).Column().AlignItems(ui.Start).Gap(2).Label(manifest.Name)
head.Children(func() {
ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() {
ui.Text(c, manifest.Name).FontSize(13).Bold()
if view.IsDefault {
ui.Badge(c, "Default")
}
})
ui.Text(c, presence(view)).FontSize(11).TextColor(t.TextMuted).SingleLine()
})
if head.Clicked() {
edit.open = !edit.open
}
if !view.IsDefault {
if ui.Button(c, "Make default").Disabled(busy).Clicked() {
id, name := manifest.ID, manifest.Name
a.run("agent:"+id, func() (string, error) {
if err := a.call("chooseDefaultAgent", map[string]string{"agentId": id}, nil); err != nil {
return "", err
}
return name + " will run the loops", nil
})
}
}
label := "Settings"
if edit.open {
label = "Hide"
}
if ui.Button(c, label).Clicked() {
edit.open = !edit.open
}
})
if !edit.open {
return
}
ui.Column(c).Gap(12).Padding(14, 16, 16, 60).BorderWidth(1, 0, 0, 0).BorderColor(t.Border).Background(t.Background).Children(func() {
if !online {
ui.Text(c, manifest.InstallHint).FontSize(12).TextColor(t.TextMuted)
}
ui.Row(c).Gap(16).AlignItems(ui.Start).Wrap().Children(func() {
ui.Field(c, "Permission", func() {
labels := make([]string, len(manifest.PermissionModes))
for i, mode := range manifest.PermissionModes {
labels[i] = modeLabels[mode]
}
ui.Select(c, &edit.mode, labels).Width(260)
})
if manifest.SupportsModel {
ui.Field(c, "Model", func() {
ui.TextInput(c, &edit.model).Placeholder(manifest.ModelPlaceholder).Width(220)
})
}
ui.Field(c, "Timeout (seconds)", func() {
ui.TextInput(c, &edit.seconds).Width(120)
})
})
mono(c, view.CommandPreview)
ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() {
saveLabel := "Save"
if busy {
saveLabel = "Working…"
}
if ui.PrimaryButton(c, saveLabel).Disabled(busy).Clicked() {
mode := view.Settings.PermissionMode
for value, label := range modeLabels {
if label == edit.mode {
mode = value
}
}
seconds, _ := strconv.Atoi(strings.TrimSpace(edit.seconds))
var model *string
if m := strings.TrimSpace(edit.model); m != "" {
model = &m
}
id, name := manifest.ID, manifest.Name
body := map[string]any{"agentId": id, "permissionMode": mode, "model": model, "timeoutMs": seconds * 1000}
a.run("agent:"+id, func() (string, error) {
if err := a.call("saveAgent", body, nil); err != nil {
return "", err
}
return "Saved " + name + " settings", nil
})
}
if view.Installed {
if ui.Button(c, "Test").Disabled(busy).Clicked() {
edit.testOutput = ""
id, name := manifest.ID, manifest.Name
a.run("agent:"+id, func() (string, error) {
var result AgentTest
if err := a.call("runAgentTest", map[string]string{"agentId": id}, &result); err != nil {
return "", err
}
text := fmt.Sprintf("Answered correctly in %.1fs.", result.DurationMs/1000)
if !result.Passed {
detail := "The agent did not answer as expected."
if result.Detail != nil {
detail = *result.Detail
}
text = strings.TrimSpace(detail + "\n" + result.Output)
}
a.update(func() { edit.testOutput = text })
if result.Passed {
return name + " is working", nil
}
return "", fmt.Errorf("%s did not pass", name)
})
}
}
})
if edit.testOutput != "" {
ui.Text(c, edit.testOutput).FontSize(12).TextColor(t.TextMuted).Selectable()
}
})
})
}
Loading
Loading