Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/omarchy.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ step does not stop the others.
| Piece | Where | Notes |
|---|---|---|
| Desktop entry | `~/.local/share/applications/HEY TUI.desktop` | Distinct from Omarchy's shipped `HEY.desktop` web app. Launches under app-id `org.omarchy.hey` |
| Menu row | marker block in `~/.config/omarchy/extensions/omarchy-menu.jsonc` | one root `HEY` row that focuses or launches the TUI; its guard is a PATH lookup, never network or `hey` itself. Becomes a submenu once there is more than one thing to open |
| Menu row | not written | the Omarchy menu belongs to the user and Omarchy, so hey adds nothing to it. Setup takes out the marker block earlier releases wrote into `~/.config/omarchy/extensions/omarchy-menu.jsonc`, leaving the rest of the file untouched |
| Bar plugin | clone under `~/.config/omarchy/plugins/37signals.hey`, entry in `~/.config/omarchy/shell.json`'s bar layout | installed and enabled automatically by the full **`hey setup`** wizard; other interactive sign-ins ask once (see below). This command also finishes an interrupted install and re-enables a plugin you disabled, verified against the running shell. `--notify` / `--no-notify` set or delete the entry's `notify` key, which the shell hot-reloads and the plugin reads to decide whether to toast. `--remove` disables the plugin and keeps its checkout. An earlier inline `hey-unread` module is removed on sight, its notify choice carried over |
| Theme template | `~/.config/omarchy/themed/hey.toml.tpl` | renders `hey.toml` into every theme so theme authors can override the overlay; triggers `omarchy-theme-refresh` |
| Keybinding | printed, never written | `o.bind("SUPER + SHIFT + ALT + H", "HEY TUI", "omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey tui")`; SUPER+SHIFT+E keeps opening the web app unless you `hl.unbind` it. Spelled out rather than `{ tui = "hey tui" }` because the lua helper quotes that into one word and the app-id derived from it would never match |

Every surface — launcher, menu, bar click, keybinding — uses the same app-id
Every surface — launcher, bar click, keybinding — uses the same app-id
(`org.omarchy.hey`) so they all focus one window. That is why the desktop entry is tiled
rather than `TUI.float`: the float class is shared by every floating TUI, and
focus-or-launch would grab whichever one was open.
Expand Down
104 changes: 15 additions & 89 deletions internal/cmd/omarchy.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import (
)

// Omarchy integration: `hey setup omarchy` installs hey-cli into the desktop
// (launcher entry, menu rows, theme template) and configures the 37signals.hey
// bar plugin, which runs `hey watch` (watch.go, watch_new.go).
// (launcher entry, theme template) and configures the 37signals.hey bar
// plugin, which runs `hey watch` (watch.go, watch_new.go).
//
// Omarchy already ships HEY as a web app (SUPER+SHIFT+E, the mailto handler, a
// HEY.desktop). Everything here complements that under its own names and never
Expand All @@ -37,7 +37,6 @@ const (
omarchyMenuBegin = " // >>> hey-cli — managed by `hey setup omarchy`, do not edit between the markers"
omarchyMenuEnd = " // <<< hey-cli"
omarchyFocusCommand = "omarchy-launch-or-focus-tui --app-id=" + omarchyAppID + " hey tui"
omarchyBarGlyph = "" // nf-fa-envelope; verified to render in the bar's JetBrainsMono Nerd Font
// The hint spells the focus command out rather than using `{ tui = "hey tui" }`:
// the lua helper shell-quotes that into one word and launch-or-focus-tui would
// derive the app-id from it, never matching the window every other surface opens.
Expand Down Expand Up @@ -152,7 +151,13 @@ type omarchySetup struct {
}

func (s omarchySetup) apply() []omarchyStep {
steps := []omarchyStep{s.installDesktop(), s.installMenu()}
steps := []omarchyStep{s.installDesktop()}
// The Omarchy menu is the user's and Omarchy's, not hey's. Earlier releases
// added a HEY row to it, so setup takes that row back out and says so only
// when there was one.
if menu := s.removeMenu(); menu.Status != "absent" {
steps = append(steps, menu)
}
steps = append(steps, s.configureBarPlugin()...)
return append(steps, s.installTemplate())
}
Expand Down Expand Up @@ -204,33 +209,9 @@ func (s omarchySetup) removeDesktop() omarchyStep {
return stepResult("desktop entry", path, changed, err, "removed", "absent")
}

// Menu: a marker-delimited block in the user's JSONC menu extension. The shell
// tolerates trailing commas and strips full-line // comments, so the block is
// inserted right after the opening brace with every row comma-terminated. One
// root row for now; it becomes a submenu when there is more than one thing to
// open. The guard is a PATH lookup — menu guards must never call hey itself.

// omarchyMenuBlock is the managed rows. The member key is hey-tui rather than
// hey so a user's own hey row can coexist instead of becoming a duplicate key.
func omarchyMenuBlock() string {
row := fmt.Sprintf(` "hey-tui": {"icon":"%s","label":"HEY","action":"%s","when":"command -v hey >/dev/null"},`,
omarchyBarGlyph, omarchyFocusCommand)
return omarchyMenuBegin + "\n" + row + "\n" + omarchyMenuEnd + "\n"
}

func (s omarchySetup) installMenu() omarchyStep {
path := s.env.menuPath()
current, err := os.ReadFile(path) // #nosec G304 -- fixed path under the user's config dir
if err != nil && !errors.Is(err, os.ErrNotExist) {
return stepResult("menu", path, false, err, "", "")
}
next, ok := insertMenuBlock(string(current), omarchyMenuBlock())
if !ok {
return stepResult("menu", path, false, errors.New("could not find the top-level object to extend"), "", "")
}
changed, err := writeFileIfChanged(path, []byte(next), 0o644)
return stepResult("menu", path, changed, err, "installed", "unchanged")
}
// Menu: earlier releases wrote a marker-delimited HEY row into the user's
// JSONC menu extension. Setup and --remove both strip that block, leaving the
// rest of the file byte for byte.

func (s omarchySetup) removeMenu() omarchyStep {
path := s.env.menuPath()
Expand All @@ -246,62 +227,6 @@ func (s omarchySetup) removeMenu() omarchyStep {
return stepResult("menu", path, changed, err, "removed", "absent")
}

// insertMenuBlock places block after the file's first structural `{`, replacing
// any earlier block. An empty file becomes a fresh object.
func insertMenuBlock(content, block string) (string, bool) {
content = stripMenuBlock(content)
if strings.TrimSpace(content) == "" {
return "{\n" + block + "}\n", true
}
idx := structuralBraceIndex(content)
if idx < 0 {
return "", false
}
head := content[:idx+1]
tail := strings.TrimLeft(content[idx+1:], " \t")
if !strings.HasPrefix(tail, "\n") {
tail = "\n" + tail
}
return head + "\n" + block + strings.TrimPrefix(tail, "\n"), true
}

// structuralBraceIndex is the index of the first `{` outside JSONC comments
// and strings, or -1 — a leading doc comment showing an object-shaped example
// must not be mistaken for the menu object itself.
func structuralBraceIndex(content string) int {
// Only whitespace and comments may precede the root token; anything else
// (an array root, a bare string) means the file is not a menu object.
for i := 0; i < len(content); i++ {
switch content[i] {
case ' ', '\t', '\r', '\n':
case '{':
return i
case '/':
if i+1 >= len(content) {
return -1
}
switch content[i+1] {
case '/':
i += 2
for i < len(content) && content[i] != '\n' {
i++
}
case '*':
end := strings.Index(content[i+2:], "*/")
if end < 0 {
return -1
}
i += 2 + end + 1
default:
return -1
}
default:
return -1
}
}
return -1
}

func stripMenuBlock(content string) string {
start := strings.Index(content, omarchyMenuBegin)
if start < 0 {
Expand Down Expand Up @@ -863,8 +788,9 @@ func newSetupOmarchyCommand() *setupOmarchyCommand {
Args: cobra.NoArgs,
Short: "Install hey into the Omarchy desktop",
Long: `Install hey into the Omarchy desktop: the 37signals.hey bar plugin, a launcher
entry, rows in the SUPER+SPACE menu, and a theme template so themes can tune the
TUI's accent colors. Every piece is idempotent.
entry, and a theme template so themes can tune the TUI's accent colors. Every
piece is idempotent. A HEY row an earlier release added to the SUPER+SPACE menu
is taken back out.

Signing in with hey offers the bar plugin on its own, asked once; this command is
the explicit path — it installs in every output format, finishes an interrupted
Expand Down
73 changes: 35 additions & 38 deletions internal/cmd/omarchy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func TestOmarchySetupInstallsEverythingOnce(t *testing.T) {
setup := omarchySetup{env: env}

first := setup.apply()
for _, name := range []string{"desktop entry", "menu", "theme template"} {
for _, name := range []string{"desktop entry", "theme template"} {
if status := statuses(first)[name]; status != "installed" {
t.Errorf("%s: first run = %q, want installed", name, status)
}
Expand All @@ -128,9 +128,11 @@ func TestOmarchySetupInstallsEverythingOnce(t *testing.T) {
t.Errorf("without a hey icon installed the entry should fall back:\n%s", desktop)
}

menu := readText(t, env.menuPath())
if !strings.Contains(menu, `"hey-tui"`) || !strings.HasPrefix(menu, "{\n"+omarchyMenuBegin) {
t.Errorf("menu block not written:\n%s", menu)
if _, err := os.Stat(env.menuPath()); !os.IsNotExist(err) {
t.Error("setup must not write to the Omarchy menu")
}
if _, ok := statuses(first)["menu"]; ok {
t.Error("with no earlier HEY row there is nothing to report about the menu")
}

if readText(t, env.templatePath()) != omarchyThemeTemplate {
Expand Down Expand Up @@ -166,8 +168,8 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) {
}
setup.apply()

if menu := readText(t, env.menuPath()); !strings.Contains(menu, `"notes"`) || !strings.Contains(menu, `"hey-tui"`) {
t.Errorf("install should keep the user's rows alongside ours:\n%s", menu)
if menu := readText(t, env.menuPath()); menu != menuBefore {
t.Errorf("setup must leave the user's menu alone:\n%s", menu)
}
if entry := pluginEntry(t, env); entry["notify"] != true {
t.Fatalf("--notify should set notify on the plugin entry, got %v", entry)
Expand All @@ -184,6 +186,10 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) {
if status != "removed" {
t.Errorf("remove disables the plugin, got %q", status)
}
case "menu":
if status != "absent" {
t.Errorf("no HEY row was in the menu, got %q", status)
}
default:
if status != "removed" {
t.Errorf("%s: remove = %q, want removed", name, status)
Expand Down Expand Up @@ -238,7 +244,7 @@ func TestOmarchySetupRejectsNonJSONShellConfig(t *testing.T) {
if steps["bar plugin"] != "failed" {
t.Errorf("a shell.json we cannot round-trip must fail rather than be rewritten, got %q", steps["bar plugin"])
}
if steps["menu"] != "installed" {
if steps["desktop entry"] != "installed" {
t.Error("one failing step must not stop the others")
}
}
Expand Down Expand Up @@ -645,43 +651,34 @@ func TestOmarchySetupKeepsForeignTemplate(t *testing.T) {
}
}

func TestInsertMenuBlockSkipsBracesInComments(t *testing.T) {
content := "// A row looks like {\"icon\": \"x\"}.\n/* or a block: { nested } */\n{\n \"mine\": {},\n}\n"
next, ok := insertMenuBlock(content, omarchyMenuBlock())
if !ok {
t.Fatal("insert failed")
}
if !strings.HasPrefix(next, "// A row looks like") {
t.Errorf("leading comments must be preserved above the block:\n%s", next)
}
if !strings.Contains(next, "*/\n{\n"+omarchyMenuBegin) {
t.Errorf("block must land after the structural brace, not a commented one:\n%s", next)
}
if _, ok := insertMenuBlock("// only a comment with { in it", omarchyMenuBlock()); ok {
t.Error("a file with no structural brace should be refused")
}
if _, ok := insertMenuBlock("[\n {\"rows\": {}}\n]\n", omarchyMenuBlock()); ok {
t.Error("an array root must be refused rather than having the block inserted into a nested object")
// Earlier releases wrote a HEY row into the Omarchy menu. Setup takes it back
// out and leaves the user's own rows and comments byte for byte; --remove on
// an already-clean menu has nothing to do.
func TestOmarchySetupRemovesEarlierMenuRow(t *testing.T) {
env, _ := testOmarchyEnv(t)
writeShell(t, env, pluginShellJSON)

mine := "{\n // my rows\n \"mine\": {\"label\":\"Mine\",\"action\":\"true\"},\n}\n"
earlier := "{\n" + omarchyMenuBegin + "\n \"hey-tui\": {\"label\":\"HEY\",\"action\":\"hey tui\"},\n" + omarchyMenuEnd + "\n // my rows\n \"mine\": {\"label\":\"Mine\",\"action\":\"true\"},\n}\n"
if err := os.MkdirAll(filepath.Dir(env.menuPath()), 0o755); err != nil {
t.Fatal(err)
}
if _, ok := insertMenuBlock(" // leading comment\n {\"mine\": {}}\n", omarchyMenuBlock()); !ok {
t.Error("a commented object root is still an object root")
if err := os.WriteFile(env.menuPath(), []byte(earlier), 0o644); err != nil {
t.Fatal(err)
}
}

func TestInsertMenuBlockReplacesStaleBlock(t *testing.T) {
stale := "{\n" + omarchyMenuBegin + "\n \"hey\": {\"label\":\"old\"},\n" + omarchyMenuEnd + "\n \"mine\": {},\n}\n"
next, ok := insertMenuBlock(stale, omarchyMenuBlock())
if !ok {
t.Fatal("insert failed")
if menu := stepNamed(omarchySetup{env: env}.apply(), "menu"); menu.Status != "removed" {
t.Errorf("setup should report taking the earlier row out, got %q", menu.Status)
}
if strings.Count(next, omarchyMenuBegin) != 1 || strings.Contains(next, `"old"`) {
t.Errorf("stale block should be replaced:\n%s", next)
if menu := readText(t, env.menuPath()); menu != mine {
t.Errorf("only the hey block should go:\n%s", menu)
}
if !strings.Contains(next, `"mine"`) {
t.Errorf("user rows lost:\n%s", next)

if _, ok := statuses(omarchySetup{env: env}.apply())["menu"]; ok {
t.Error("a second run has nothing to say about the menu")
}
if _, ok := insertMenuBlock("not json at all", omarchyMenuBlock()); ok {
t.Error("a file with no object should be refused")
if menu := stepNamed(omarchySetup{env: env, forcePlugin: true}.remove(), "menu"); menu.Status != "absent" {
t.Errorf("remove on a clean menu should report absent, got %q", menu.Status)
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutco
barLine += " — " + bar.Detail
}
fmt.Fprintln(w, "Bar plugin: "+barLine)
desktop := "launcher entry, menu row and theme template in place"
desktop := "launcher entry and theme template in place"
for _, step := range result.Omarchy.Steps {
if step.Name != "bar plugin" && step.Status == "failed" {
desktop = step.Name + " failed: " + step.Detail
Expand Down
Loading