Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e65139610 |
+25
-13
@@ -298,23 +298,35 @@ deliberately separate:
|
||||
confirmation** the page can't forge or auto-dismiss (persisted per server
|
||||
origin, so a trusted server is asked only once).
|
||||
|
||||
### Detecting the CLI (setup page)
|
||||
### Detecting the CLI and customizing its path
|
||||
|
||||
On the setup page the shell probes for the `omnigent` binary —
|
||||
`settings.omnigent_path` first, then `PATH`, then the well-known install
|
||||
locations (`~/.local/bin`, `~/.cargo/bin`, Homebrew, `/usr/local/bin`). A
|
||||
GUI-launched app inherits a minimal `PATH`, which is why the install locations
|
||||
are probed directly. When the CLI isn't found, the page shows the install
|
||||
one-liner
|
||||
The CLI ships under two names that resolve to the same entry point — `omnigent`
|
||||
(canonical) and `omni` (short alias) — and the shell probes **both**:
|
||||
`settings.omnigent_path` first, then `PATH` (`omnigent` then `omni`), then the
|
||||
well-known install locations (`~/.local/bin`, `~/.cargo/bin`, Homebrew,
|
||||
`/usr/local/bin`, each tried under both names). A GUI-launched app inherits a
|
||||
minimal `PATH`, which is why the install locations are probed directly. The path
|
||||
is resolved once at startup and cached in-memory for the session.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh
|
||||
```
|
||||
You can see and change which binary is used in two places:
|
||||
|
||||
- **Setup page** — hidden by default behind a **gear icon** (top-right) that
|
||||
opens a small modal. The resolved/auto-detected path shows as the field's
|
||||
**placeholder** (the value stays empty until you type an override); set it via
|
||||
free-text or a native file picker. When nothing is found the gear gets an
|
||||
accent dot and the modal shows the install one-liner
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh
|
||||
```
|
||||
- **In-app** — **Settings → Local CLI** (desktop only): shows the resolved path
|
||||
and version, a **Change…** button (native file picker) and **Reset to
|
||||
auto-detected**. For safety the in-app surface exposes **no free-text setter**
|
||||
— a connected server must not be able to silently repoint the CLI at an
|
||||
arbitrary binary, so changing it requires a user-driven OS dialog.
|
||||
|
||||
and a field to point the app at the binary (typed or via a native file picker).
|
||||
A configured path is saved to `settings.json` (`omnigent_path`) only once it
|
||||
validates as a runnable `omnigent`. Connecting to a **remote** server never
|
||||
needs the CLI — only "Start locally" and hosting do.
|
||||
validates as a runnable CLI; clearing it reverts to auto-detection. Connecting
|
||||
to a **remote** server never needs the CLI — only "Start locally" and hosting do.
|
||||
|
||||
### Start locally
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
padding: 0 16px;
|
||||
/* App chrome, not a document — suppress text selection everywhere
|
||||
except the input fields (re-enabled below). */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
@@ -79,6 +83,9 @@
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
outline: none;
|
||||
/* Re-enable selection in the editable fields (body suppresses it). */
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
@@ -112,7 +119,7 @@
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
.recents {
|
||||
margin-top: 24px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.recents-title {
|
||||
margin: 0 0 8px;
|
||||
@@ -171,39 +178,97 @@
|
||||
#start-local:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary) 90%, transparent);
|
||||
}
|
||||
.cli-panel {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
/* Settings gear (top-right) — opens the Omnigent CLI modal. no-drag so it's
|
||||
clickable over the drag strip. */
|
||||
.gear-btn {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
-webkit-app-region: no-drag;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.gear-btn:hover {
|
||||
color: var(--foreground);
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
/* A small accent dot draws the eye to the gear when the CLI is missing. */
|
||||
.gear-btn.attention::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--destructive);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: color-mix(in srgb, #000 45%, transparent);
|
||||
}
|
||||
/* The author `display: flex` above outranks the UA `[hidden]` rule, so
|
||||
hiding needs an explicit, higher-specificity rule — without this the
|
||||
modal shows on load and won't close. */
|
||||
.modal-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.cli-panel .title {
|
||||
font-weight: 500;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.cli-panel p {
|
||||
.modal p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.cli-cmd {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
margin: 8px 0;
|
||||
.modal a {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cli-cmd code {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
.modal-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.mini-btn,
|
||||
.path-row button {
|
||||
.modal-close {
|
||||
margin-top: 14px;
|
||||
padding: 9px 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: color-mix(in srgb, var(--primary) 90%, transparent);
|
||||
}
|
||||
#cli-path-label {
|
||||
font-weight: 500;
|
||||
margin: 10px 0 2px;
|
||||
}
|
||||
.path-row button,
|
||||
#cli-redetect {
|
||||
width: auto;
|
||||
flex: none;
|
||||
padding: 6px 12px;
|
||||
@@ -212,6 +277,12 @@
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
}
|
||||
#cli-redetect {
|
||||
margin-top: 8px;
|
||||
}
|
||||
#cli-redetect:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
.path-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -244,6 +315,32 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="drag-strip"></div>
|
||||
<button
|
||||
type="button"
|
||||
id="cli-gear"
|
||||
class="gear-btn"
|
||||
aria-label="Configure the Omnigent CLI"
|
||||
title="Configure the Omnigent CLI"
|
||||
hidden
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="card">
|
||||
<picture>
|
||||
<source
|
||||
@@ -254,31 +351,9 @@
|
||||
</picture>
|
||||
<p class="sub">Run an Omnigents server on this machine, or connect to an existing one.</p>
|
||||
<div id="local-section">
|
||||
<button id="start-local" disabled>Start locally</button>
|
||||
|
||||
<div class="cli-panel" id="cli-panel" hidden>
|
||||
<p class="title" id="cli-title"></p>
|
||||
<div id="cli-install" hidden>
|
||||
<p>Install the Omnigent CLI to run a server locally:</p>
|
||||
<div class="cli-cmd">
|
||||
<code id="cli-cmd-text"></code>
|
||||
<button type="button" class="mini-btn" id="cli-copy">Copy</button>
|
||||
</div>
|
||||
<p>Already installed? Point the app at the binary:</p>
|
||||
<div class="path-row">
|
||||
<input
|
||||
id="cli-path"
|
||||
type="text"
|
||||
placeholder="/path/to/omnigent"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button type="button" id="cli-browse">Browse…</button>
|
||||
</div>
|
||||
<p class="cli-note" id="cli-path-note"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Always enabled: when the CLI is missing this opens the settings
|
||||
modal (install / set path) instead of failing to start. -->
|
||||
<button id="start-local">Start locally</button>
|
||||
<div class="divider">or connect to a server</div>
|
||||
</div>
|
||||
|
||||
@@ -298,6 +373,39 @@
|
||||
<div id="recents-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Omnigent CLI settings, opened from the gear. Hidden by default. -->
|
||||
<div class="modal-overlay" id="cli-modal" hidden>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Omnigent CLI settings">
|
||||
<p class="modal-title">Omnigent CLI</p>
|
||||
<p class="cli-note" id="cli-status"></p>
|
||||
<div id="cli-install" hidden>
|
||||
<p>
|
||||
<a
|
||||
href="https://omnigent.ai/quickstart/install#install-omnigent"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Install the Omnigent CLI ↗</a
|
||||
>
|
||||
</p>
|
||||
<p>Already installed it? Re-detect, or set the path below.</p>
|
||||
<button type="button" id="cli-redetect">Re-detect</button>
|
||||
</div>
|
||||
<p id="cli-path-label">Path to the Omnigent CLI</p>
|
||||
<div class="path-row">
|
||||
<input
|
||||
id="cli-path"
|
||||
type="text"
|
||||
placeholder="/path/to/omni"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button type="button" id="cli-browse">Browse…</button>
|
||||
</div>
|
||||
<p class="cli-note" id="cli-path-note"></p>
|
||||
<button type="button" id="cli-modal-close" class="modal-close">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="../src/url.js"></script>
|
||||
<script>
|
||||
// Shared URL helpers (electron/src/url.js), exposed as window.omnigentUrl
|
||||
@@ -413,33 +521,18 @@
|
||||
// binary. Remote Connect never depends on the CLI.
|
||||
const startLocalBtn = document.getElementById("start-local");
|
||||
const localSection = document.getElementById("local-section");
|
||||
const cliPanel = document.getElementById("cli-panel");
|
||||
const cliTitle = document.getElementById("cli-title");
|
||||
const cliGear = document.getElementById("cli-gear");
|
||||
const cliModal = document.getElementById("cli-modal");
|
||||
const cliModalClose = document.getElementById("cli-modal-close");
|
||||
const cliStatus = document.getElementById("cli-status");
|
||||
const cliInstall = document.getElementById("cli-install");
|
||||
const cliCmdText = document.getElementById("cli-cmd-text");
|
||||
const cliCopy = document.getElementById("cli-copy");
|
||||
const cliRedetect = document.getElementById("cli-redetect");
|
||||
const cliPathInput = document.getElementById("cli-path");
|
||||
const cliBrowse = document.getElementById("cli-browse");
|
||||
const cliPathNote = document.getElementById("cli-path-note");
|
||||
|
||||
// Copy without a clipboard permission: a transient off-screen textarea +
|
||||
// execCommand works on a user gesture in the file:// setup page.
|
||||
function copyText(text) {
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Tracks the last resolved install state so the Start-locally click can
|
||||
// open settings (instead of failing) when the CLI isn't available.
|
||||
let cliInstalled = false;
|
||||
|
||||
async function refreshCliStatus() {
|
||||
let status;
|
||||
@@ -448,24 +541,30 @@
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// textContent, never innerHTML: the command and version come from the
|
||||
// main process and are rendered as inert text.
|
||||
cliCmdText.textContent = status.installCommand || "";
|
||||
const installed = Boolean(status.installed);
|
||||
// Starting a local server needs the CLI; gate the button on it.
|
||||
startLocalBtn.disabled = !installed;
|
||||
if (installed) {
|
||||
cliPanel.hidden = true;
|
||||
} else {
|
||||
cliPanel.hidden = false;
|
||||
cliInstall.hidden = false;
|
||||
cliTitle.textContent = "Omnigent CLI not found";
|
||||
}
|
||||
cliInstalled = installed;
|
||||
// Modal contents: install one-liner only when missing. The resolved /
|
||||
// auto-detected path is shown as the field's PLACEHOLDER (the value
|
||||
// stays empty until the user types an override), so the field reads as
|
||||
// "auto" by default.
|
||||
cliInstall.hidden = installed;
|
||||
cliStatus.textContent = installed
|
||||
? status.version
|
||||
? `Found: ${status.version}`
|
||||
: "Omnigent CLI found."
|
||||
: "Omnigent CLI not found.";
|
||||
cliPathInput.placeholder = status.path || "/path/to/omni";
|
||||
// Draw the eye to the (otherwise quiet) gear when the CLI is missing.
|
||||
cliGear.classList.toggle("attention", !installed);
|
||||
}
|
||||
|
||||
// Validate + persist the typed path. Returns true when there's nothing to
|
||||
// do (empty) or the path was accepted — i.e. it's safe to close the modal;
|
||||
// false when a non-empty path was rejected (keep the modal open with the
|
||||
// error showing).
|
||||
async function applyCliPath(value) {
|
||||
const p = (value || "").trim();
|
||||
if (p === "") return;
|
||||
if (p === "") return true;
|
||||
cliPathNote.className = "cli-note";
|
||||
cliPathNote.textContent = "Checking…";
|
||||
let result;
|
||||
@@ -475,22 +574,43 @@
|
||||
result = { accepted: false };
|
||||
}
|
||||
if (result && result.accepted) {
|
||||
cliPathNote.textContent = result.version ? `Found: ${result.version}` : "Found omnigent.";
|
||||
cliPathNote.textContent = result.version
|
||||
? `Found: ${result.version}`
|
||||
: "Found the Omnigent CLI.";
|
||||
// Clear the value so the field reverts to showing the (now-updated)
|
||||
// resolved path as its placeholder.
|
||||
cliPathInput.value = "";
|
||||
await refreshCliStatus();
|
||||
} else {
|
||||
cliPathNote.className = "cli-note bad";
|
||||
cliPathNote.textContent = "That path is not a runnable omnigent binary.";
|
||||
return true;
|
||||
}
|
||||
cliPathNote.className = "cli-note bad";
|
||||
cliPathNote.textContent = "That path is not a runnable Omnigent CLI.";
|
||||
return false;
|
||||
}
|
||||
|
||||
function openCliModal() {
|
||||
cliModal.hidden = false;
|
||||
void refreshCliStatus();
|
||||
}
|
||||
function closeCliModal() {
|
||||
cliModal.hidden = true;
|
||||
}
|
||||
|
||||
if (setup.getCliStatus) {
|
||||
cliCopy.addEventListener("click", () => {
|
||||
if (copyText(cliCmdText.textContent)) {
|
||||
cliCopy.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
cliCopy.textContent = "Copy";
|
||||
}, 1500);
|
||||
}
|
||||
// The gear is the only entry point to the CLI settings — hidden by
|
||||
// default, revealed once we know the bridge exists.
|
||||
cliGear.hidden = false;
|
||||
cliGear.addEventListener("click", openCliModal);
|
||||
// "Done" commits the typed path (it isn't applied on every keystroke).
|
||||
// On an invalid path, keep the modal open so the error stays visible.
|
||||
cliModalClose.addEventListener("click", async () => {
|
||||
if (await applyCliPath(cliPathInput.value)) closeCliModal();
|
||||
});
|
||||
cliModal.addEventListener("click", (e) => {
|
||||
if (e.target === cliModal) closeCliModal(); // backdrop click
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !cliModal.hidden) closeCliModal();
|
||||
});
|
||||
cliBrowse.addEventListener("click", async () => {
|
||||
const picked = await setup.browseCliPath();
|
||||
@@ -502,7 +622,18 @@
|
||||
cliPathInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") applyCliPath(cliPathInput.value);
|
||||
});
|
||||
// Re-run auto-detection (getCliStatus re-resolves every call) — the
|
||||
// post-install path: install via the link, then click to pick it up.
|
||||
cliRedetect.addEventListener("click", async () => {
|
||||
cliStatus.textContent = "Checking…";
|
||||
await refreshCliStatus();
|
||||
});
|
||||
startLocalBtn.addEventListener("click", async () => {
|
||||
// No CLI yet → open settings (install / set path) rather than fail.
|
||||
if (!cliInstalled) {
|
||||
openCliModal();
|
||||
return;
|
||||
}
|
||||
err.textContent = "";
|
||||
const prev = startLocalBtn.textContent;
|
||||
startLocalBtn.disabled = true;
|
||||
@@ -524,7 +655,8 @@
|
||||
startLocalBtn.textContent = prev;
|
||||
startLocalBtn.disabled = false;
|
||||
});
|
||||
// Resolve CLI status (enables/disables Start-locally).
|
||||
// Resolve CLI status so the Start-locally click knows whether to start
|
||||
// or open settings, and so the gear's attention dot reflects reality.
|
||||
refreshCliStatus();
|
||||
} else {
|
||||
// Older shell without the CLI bridge: hide the whole local section.
|
||||
|
||||
+71
-16
@@ -653,6 +653,45 @@ function resolvedCliPath() {
|
||||
return cachedCli.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `configuredPath` as a runnable CLI and persist it as the override
|
||||
* when it checks out; an empty string clears the override (revert to PATH /
|
||||
* candidates). A typo is NOT saved (so it can't mask a working PATH lookup).
|
||||
* Returns the resulting CLI status plus whether the path was accepted. Shared
|
||||
* by the setup page (free-text) and the in-app picker.
|
||||
*
|
||||
* @param {string} configuredPath
|
||||
* @returns {Promise<Record<string, unknown> & { accepted: boolean }>}
|
||||
*/
|
||||
async function applyCliPath(configuredPath) {
|
||||
const trimmed = String(configuredPath ?? "").trim();
|
||||
const status = await omnigentCli.getCliStatus(trimmed || null);
|
||||
const accepted = status.installed && status.source === "configured";
|
||||
if (accepted) {
|
||||
const settings = loadSettings();
|
||||
settings.omnigent_path = trimmed;
|
||||
saveSettings(settings);
|
||||
} else if (trimmed === "") {
|
||||
const settings = loadSettings();
|
||||
delete settings.omnigent_path;
|
||||
saveSettings(settings);
|
||||
}
|
||||
return { ...status, accepted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any saved CLI-path override so resolution falls back to PATH and the
|
||||
* well-known install locations, then report the freshly-resolved status.
|
||||
*
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
async function clearCliPath() {
|
||||
const settings = loadSettings();
|
||||
delete settings.omnigent_path;
|
||||
saveSettings(settings);
|
||||
return omnigentCli.getCliStatus(null);
|
||||
}
|
||||
|
||||
/** Maximum number of entries kept in the persisted recent-servers list. */
|
||||
const MAX_RECENT_SERVERS = 5;
|
||||
|
||||
@@ -809,7 +848,9 @@ function createWindow(targetUrl, opts = {}) {
|
||||
// Without saved coordinates Electron centers the window.
|
||||
...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}),
|
||||
minWidth: 720,
|
||||
minHeight: 480,
|
||||
// Tall enough that the bundled setup page (logo, Start-locally, divider,
|
||||
// URL field, Connect, and a few recents) fits without overflowing.
|
||||
minHeight: 600,
|
||||
title: "Omnigent",
|
||||
backgroundColor: "#0b0b0c",
|
||||
// macOS: hide the native title bar but keep the traffic lights, inset
|
||||
@@ -1776,20 +1817,7 @@ function registerIpc() {
|
||||
if (!isSetupPageSender(event)) {
|
||||
throw new Error("set-cli-path is only available to the setup page");
|
||||
}
|
||||
const trimmed = String(configuredPath ?? "").trim();
|
||||
const status = await omnigentCli.getCliStatus(trimmed || null);
|
||||
const accepted = status.installed && status.source === "configured";
|
||||
if (accepted) {
|
||||
const settings = loadSettings();
|
||||
settings.omnigent_path = trimmed;
|
||||
saveSettings(settings);
|
||||
} else if (trimmed === "") {
|
||||
// Empty input clears any saved override (revert to PATH/candidates).
|
||||
const settings = loadSettings();
|
||||
delete settings.omnigent_path;
|
||||
saveSettings(settings);
|
||||
}
|
||||
return { ...status, accepted };
|
||||
return applyCliPath(configuredPath);
|
||||
});
|
||||
|
||||
// Setup page → native file picker for the omnigent binary. Returns the chosen
|
||||
@@ -1800,7 +1828,7 @@ function registerIpc() {
|
||||
}
|
||||
const win = BrowserWindow.fromWebContents(event.sender) ?? activeWindow();
|
||||
const result = await dialog.showOpenDialog(win ?? undefined, {
|
||||
title: "Locate the omnigent binary",
|
||||
title: "Locate the Omnigent CLI binary",
|
||||
properties: ["openFile"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
@@ -1832,6 +1860,29 @@ function registerIpc() {
|
||||
return { cliInstalled: Boolean(resolvedCliPath()), hostId: omnigentCli.localHostId() };
|
||||
});
|
||||
|
||||
// SPA (in-app Settings → Local CLI) → is the CLI installed and runnable,
|
||||
// plus the resolved path / version / source. Read-only; pinned-origin gated.
|
||||
ipcMain.handle("omnigent:cli-get-status", async (event) => {
|
||||
if (!isPinnedOriginSender(event)) {
|
||||
console.warn("[omnigent] cli-get-status from untrusted sender dropped");
|
||||
return null;
|
||||
}
|
||||
return omnigentCli.getCliStatus(loadSettings().omnigent_path);
|
||||
});
|
||||
|
||||
// SPA → reset to auto-detected (clear the override). Chooses no path itself,
|
||||
// so it's safe to expose to the SPA. SETTING a path is deliberately NOT
|
||||
// exposed here: a connected (remote, semi-trusted) server could otherwise
|
||||
// point the CLI at an arbitrary binary that host-control would later spawn
|
||||
// (and validation runs `<path> --version`). Choosing a path stays on the
|
||||
// bundled file:// setup page.
|
||||
ipcMain.handle("omnigent:cli-reset-path", async (event) => {
|
||||
if (!isPinnedOriginSender(event)) {
|
||||
throw new Error("cli-reset-path is only available to a connected server page");
|
||||
}
|
||||
return clearCliPath();
|
||||
});
|
||||
|
||||
// SPA → start / stop / restart this machine's host daemon for the window's
|
||||
// own server (the host selection menu's "connect this machine" action).
|
||||
ipcMain.handle("omnigent:host-control", async (event, action) => {
|
||||
@@ -1908,6 +1959,10 @@ if (!gotLock) {
|
||||
registerWebAuthn();
|
||||
registerIpc();
|
||||
buildMenu();
|
||||
// Resolve the CLI path once at startup so the first status/control call is
|
||||
// instant (primes the in-memory cache in resolvedCliPath); also lets the
|
||||
// setup page / Local CLI settings pre-fill the resolved path immediately.
|
||||
resolvedCliPath();
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
|
||||
@@ -252,7 +252,16 @@ async function localServerHealthy(timeoutMs = 1500) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Well-known install locations for the `omnigent` binary, in priority order.
|
||||
* The CLI binary's two console-script names — both resolve to the same entry
|
||||
* point (`omnigent.cli:main`); `omni` is the short alias. We probe `omnigent`
|
||||
* first (canonical) but accept `omni` so a machine that only installed the
|
||||
* alias still resolves. See pyproject.toml `[project.scripts]`.
|
||||
*/
|
||||
const CLI_NAMES = ["omnigent", "omni"];
|
||||
|
||||
/**
|
||||
* Well-known install locations for the CLI binary, in priority order. For each
|
||||
* directory we list the `omnigent` name then the `omni` alias.
|
||||
* `uv tool install` (the documented installer) drops it in ~/.local/bin;
|
||||
* the rest cover Homebrew and source/cargo installs. Probing these matters
|
||||
* because a GUI-launched Electron app inherits a minimal PATH that usually
|
||||
@@ -262,12 +271,13 @@ async function localServerHealthy(timeoutMs = 1500) {
|
||||
*/
|
||||
function candidatePaths() {
|
||||
const home = os.homedir();
|
||||
return [
|
||||
path.join(home, ".local", "bin", "omnigent"),
|
||||
path.join(home, ".cargo", "bin", "omnigent"),
|
||||
"/opt/homebrew/bin/omnigent",
|
||||
"/usr/local/bin/omnigent",
|
||||
const dirs = [
|
||||
path.join(home, ".local", "bin"),
|
||||
path.join(home, ".cargo", "bin"),
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
];
|
||||
return dirs.flatMap((dir) => CLI_NAMES.map((name) => path.join(dir, name)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,19 +297,20 @@ function isExecutableFile(p) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `omnigent` on PATH (or the user's login shell PATH). Returns null
|
||||
* when not found. On POSIX we go through `command -v` so shell-managed PATHs
|
||||
* (uv shims) resolve; on Windows we use `where`.
|
||||
* Resolve the CLI on PATH (or the user's login shell PATH) by name. Returns
|
||||
* null when not found. On POSIX we go through `command -v` so shell-managed
|
||||
* PATHs (uv shims) resolve; on Windows we use `where`.
|
||||
*
|
||||
* @param {string} name e.g. "omnigent" or "omni"
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function whichOmnigent() {
|
||||
function whichName(name) {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
const out = execFileSync("where", ["omnigent"], { encoding: "utf8" });
|
||||
const out = execFileSync("where", [name], { encoding: "utf8" });
|
||||
return out.trim().split(/\r?\n/)[0] || null;
|
||||
}
|
||||
const out = execFileSync("/bin/sh", ["-c", "command -v omnigent"], {
|
||||
const out = execFileSync("/bin/sh", ["-c", `command -v ${name}`], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return out.trim() || null;
|
||||
@@ -308,6 +319,20 @@ function whichOmnigent() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CLI on PATH, trying `omnigent` then the `omni` alias. Returns the
|
||||
* first hit, or null when neither is on PATH.
|
||||
*
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function whichOmnigent() {
|
||||
for (const name of CLI_NAMES) {
|
||||
const found = whichName(name);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the `omnigent` binary. Resolution order: a user-configured path, then
|
||||
* PATH, then the well-known candidate locations. Returns the resolved path and
|
||||
@@ -467,11 +492,16 @@ async function getCliStatus(configuredPath) {
|
||||
};
|
||||
}
|
||||
const res = await runCli(resolved.path, ["--version"], { timeoutMs: 5000 });
|
||||
const ok = res.code === 0;
|
||||
const version = res.stdout.trim() || res.stderr.trim() || "";
|
||||
// Must exit cleanly AND identify itself as omni — `omnigent --version` prints
|
||||
// e.g. "omnigent 0.3.0.dev0 (…)". The exit-code alone isn't enough: an
|
||||
// unrelated binary (e.g. /bin/echo) also exits 0 on `--version`, and we must
|
||||
// not accept it as the CLI (it would later fail to run a server / host).
|
||||
const ok = res.code === 0 && /\bomni/i.test(version);
|
||||
return {
|
||||
installed: ok,
|
||||
path: ok ? resolved.path : null,
|
||||
version: ok ? res.stdout.trim() || res.stderr.trim() || null : null,
|
||||
version: ok ? version || null : null,
|
||||
source: ok ? resolved.source : null,
|
||||
installCommand: INSTALL_COMMAND,
|
||||
};
|
||||
|
||||
@@ -92,6 +92,18 @@ contextBridge.exposeInMainWorld("omnigentDesktop", {
|
||||
ipcRenderer.on("omnigent:host-status-changed", listener);
|
||||
return () => ipcRenderer.removeListener("omnigent:host-status-changed", listener);
|
||||
},
|
||||
/**
|
||||
* The local `omni` CLI status — `{ installed, path, version, source,
|
||||
* installCommand }`. Read-only; lets the in-app Local CLI settings show which
|
||||
* binary is in use.
|
||||
*/
|
||||
getCliStatus: () => ipcRenderer.invoke("omnigent:cli-get-status"),
|
||||
/**
|
||||
* Clear the saved CLI-path override (revert to auto-detection). The SPA can
|
||||
* reset but cannot SET a path: choosing a binary is restricted to the trusted
|
||||
* setup page, so a connected server can't repoint the CLI at an arbitrary one.
|
||||
*/
|
||||
resetCliPath: () => ipcRenderer.invoke("omnigent:cli-reset-path"),
|
||||
});
|
||||
|
||||
// Setup-page bridge: persist + navigate to a server URL, and read the saved
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
isLoopbackServer,
|
||||
sameLoopbackServer,
|
||||
parseLocalServerPidfile,
|
||||
candidatePaths,
|
||||
resolveCliPath,
|
||||
parseJsonLoose,
|
||||
matchesServer,
|
||||
@@ -78,7 +79,35 @@ describe("parseLocalServerPidfile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidatePaths", () => {
|
||||
it("probes both the omnigent name and the omni alias in each location", () => {
|
||||
const paths = candidatePaths();
|
||||
// Every well-known dir contributes an `omnigent` and an `omni` entry.
|
||||
assert.ok(paths.some((p) => p.endsWith("/.local/bin/omnigent")));
|
||||
assert.ok(paths.some((p) => p.endsWith("/.local/bin/omni")));
|
||||
assert.ok(paths.includes("/opt/homebrew/bin/omnigent"));
|
||||
assert.ok(paths.includes("/opt/homebrew/bin/omni"));
|
||||
assert.ok(paths.includes("/usr/local/bin/omni"));
|
||||
});
|
||||
|
||||
it("lists the canonical omnigent name before the omni alias within a dir", () => {
|
||||
const paths = candidatePaths();
|
||||
const og = paths.indexOf("/opt/homebrew/bin/omnigent");
|
||||
const omni = paths.indexOf("/opt/homebrew/bin/omni");
|
||||
assert.ok(og !== -1 && omni !== -1 && og < omni);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCliPath", () => {
|
||||
it("resolves the omni alias when only it is executable", () => {
|
||||
const got = resolveCliPath(null, {
|
||||
isExecutableFile: (p) => p === "/home/me/.local/bin/omni",
|
||||
whichOmnigent: () => null,
|
||||
candidatePaths: () => ["/home/me/.local/bin/omnigent", "/home/me/.local/bin/omni"],
|
||||
});
|
||||
assert.deepEqual(got, { path: "/home/me/.local/bin/omni", source: "candidate" });
|
||||
});
|
||||
|
||||
it("prefers a usable configured path", () => {
|
||||
const got = resolveCliPath("/custom/omnigent", {
|
||||
isExecutableFile: (p) => p === "/custom/omnigent",
|
||||
|
||||
@@ -126,11 +126,31 @@ interface ElectronDesktopApi extends NativeShellApi {
|
||||
controlHost?: (action: HostControlAction) => Promise<HostActionResult>;
|
||||
/** Subscribe to host status-change pings (re-read on fire); returns an unsubscribe. */
|
||||
onHostStatusChanged?: (callback: () => void) => () => void;
|
||||
/** The local `omni` CLI status (installed, resolved path, version, source). */
|
||||
getCliStatus?: () => Promise<CliStatus | null>;
|
||||
/** Clear the CLI-path override (revert to auto-detection); resolves status. */
|
||||
resetCliPath?: () => Promise<CliStatus | null>;
|
||||
}
|
||||
|
||||
/** A lifecycle action for the host daemon. */
|
||||
export type HostControlAction = "start" | "stop" | "restart";
|
||||
|
||||
/** Status of the local `omni` CLI, from the desktop shell. */
|
||||
export interface CliStatus {
|
||||
/** Whether the CLI was found and is runnable. */
|
||||
installed: boolean;
|
||||
/** The resolved binary path (configured override or auto-detected), or null. */
|
||||
path: string | null;
|
||||
/** The CLI's reported version, or null. */
|
||||
version: string | null;
|
||||
/** How the path was resolved: an explicit override, PATH, or a known location. */
|
||||
source: "configured" | "path" | "candidate" | null;
|
||||
/** The install one-liner to show when the CLI is missing. */
|
||||
installCommand: string;
|
||||
/** Whether a just-submitted path was accepted (present on pick/set results). */
|
||||
accepted?: boolean;
|
||||
}
|
||||
|
||||
/** This machine's identity, read from local config (fast — no subprocess). */
|
||||
export interface HostIdentity {
|
||||
/** Whether the `omnigent` CLI was found and is runnable. */
|
||||
@@ -491,3 +511,34 @@ export function onHostStatusChanged(callback: () => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the local `omni` CLI status from the desktop shell (installed, resolved
|
||||
* path, version, source). Resolves `null` outside the Electron shell or under a
|
||||
* shell too old to expose the CLI bridge.
|
||||
*/
|
||||
export async function getCliStatus(): Promise<CliStatus | null> {
|
||||
const electron = electronApi();
|
||||
if (!electron?.getCliStatus) return null;
|
||||
try {
|
||||
return await electron.getCliStatus();
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] electron getCliStatus failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the saved CLI-path override so the shell reverts to auto-detection,
|
||||
* then resolve the freshly-detected status. Resolves `null` outside the shell.
|
||||
*/
|
||||
export async function resetCliPath(): Promise<CliStatus | null> {
|
||||
const electron = electronApi();
|
||||
if (!electron?.resetCliPath) return null;
|
||||
try {
|
||||
return await electron.resetCliPath();
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] electron resetCliPath failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ import { absoluteTime } from "@/lib/relativeTime";
|
||||
import { useSettingsRoute } from "@/shell/settingsNav";
|
||||
import { type ThemeMode, normalizeThemeMode } from "@/components/theme/themeMode";
|
||||
import { useIsEmbedded } from "@/lib/embedded";
|
||||
import { type CliStatus, getCliStatus, isElectronShell, resetCliPath } from "@/lib/nativeBridge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -77,6 +78,7 @@ export function SettingsPage() {
|
||||
{section === "shortcuts" && <ShortcutsSection />}
|
||||
{section === "account" && accountsEnabled && <AccountSection />}
|
||||
{section === "archived" && <ArchivedSection />}
|
||||
{section === "cli" && isElectronShell() && <LocalCliSection />}
|
||||
</PageScroll>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +157,102 @@ function ShortcutsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-only: shows which Omnigent CLI binary the shell resolved
|
||||
* (auto-detected or a custom override). Read-only — setting a custom path is
|
||||
* done on the connect/setup screen (the trusted surface that allows free-text
|
||||
* entry); the SPA exposes no path setter. A safe "reset to auto-detected" stays
|
||||
* here since it chooses no path.
|
||||
*/
|
||||
function LocalCliSection() {
|
||||
const [status, setStatus] = useState<CliStatus | null | "loading">("loading");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void getCliStatus().then(setStatus);
|
||||
}, []);
|
||||
|
||||
const onReset = useCallback(async () => {
|
||||
setBusy(true);
|
||||
const next = await resetCliPath();
|
||||
setBusy(false);
|
||||
if (next) setStatus(next); // null only when the bridge is missing (old shell)
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Section title="Local CLI">
|
||||
<p className="text-sm text-muted-foreground">Checking…</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Local CLI"
|
||||
description="The Omnigent command-line tool this app uses to run a local server and connect this machine as a runner."
|
||||
>
|
||||
{status === null ? (
|
||||
<p className="text-sm text-muted-foreground">CLI status is unavailable.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
status.installed ? "bg-success" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
{status.installed
|
||||
? `Found${status.version ? ` · ${status.version}` : ""}`
|
||||
: "Not found"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status.path ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status.source === "configured" ? "Path (custom)" : "Path (auto-detected)"}
|
||||
</span>
|
||||
<code className="block overflow-x-auto rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
|
||||
{status.path}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The Omnigent CLI wasn't found. Install it, then set its path from the connect
|
||||
screen:
|
||||
</p>
|
||||
{status.installCommand && (
|
||||
<code className="block overflow-x-auto rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
|
||||
{status.installCommand}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For security, a custom path can only be set from the connect screen — this prevents a
|
||||
connected server from pointing the app at a different binary. Open it from the Server
|
||||
menu (Change Server…) and use the settings gear.
|
||||
</p>
|
||||
|
||||
{status.source === "configured" && (
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void onReset()}>
|
||||
Reset to auto-detected
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSection() {
|
||||
const [me, setMe] = useState<CurrentAccount | null | "unknown">("unknown");
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ afterEach(cleanup);
|
||||
|
||||
describe("settingsNavGroups", () => {
|
||||
it("flags Keyboard shortcuts as hidden on mobile, but not the other items", () => {
|
||||
const items = settingsNavGroups(false).flatMap((g) => g.items);
|
||||
const items = settingsNavGroups(false, false).flatMap((g) => g.items);
|
||||
const shortcuts = items.find((i) => i.id === "shortcuts");
|
||||
expect(shortcuts?.hideOnMobile).toBe(true);
|
||||
for (const item of items) {
|
||||
@@ -48,17 +48,26 @@ describe("settingsNavGroups", () => {
|
||||
|
||||
it("includes Account (leading) only when accounts auth is enabled", () => {
|
||||
expect(
|
||||
settingsNavGroups(false)
|
||||
settingsNavGroups(false, false)
|
||||
.flatMap((g) => g.items)
|
||||
.map((i) => i.id),
|
||||
).not.toContain("account");
|
||||
const withAccounts = settingsNavGroups(true)
|
||||
const withAccounts = settingsNavGroups(true, false)
|
||||
.flatMap((g) => g.items)
|
||||
.map((i) => i.id);
|
||||
expect(withAccounts).toContain("account");
|
||||
// Account leads its group — it's the most-visited section on accounts deploys.
|
||||
expect(withAccounts[0]).toBe("account");
|
||||
});
|
||||
|
||||
it("includes the Local CLI section only in the desktop shell", () => {
|
||||
const ids = (isDesktop: boolean) =>
|
||||
settingsNavGroups(false, isDesktop)
|
||||
.flatMap((g) => g.items)
|
||||
.map((i) => i.id);
|
||||
expect(ids(false)).not.toContain("cli");
|
||||
expect(ids(true)).toContain("cli");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsSidebarBody", () => {
|
||||
|
||||
@@ -12,21 +12,24 @@ import {
|
||||
KeyboardIcon,
|
||||
PaletteIcon,
|
||||
PanelRightOpenIcon,
|
||||
TerminalIcon,
|
||||
UserCogIcon,
|
||||
} from "lucide-react";
|
||||
import { Link, useLocation } from "@/lib/routing";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useServerInfo } from "@/lib/CapabilitiesContext";
|
||||
import { isElectronShell } from "@/lib/nativeBridge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SettingsSectionId = "appearance" | "shortcuts" | "account" | "archived";
|
||||
export type SettingsSectionId = "appearance" | "shortcuts" | "account" | "archived" | "cli";
|
||||
|
||||
const SECTION_IDS: readonly SettingsSectionId[] = [
|
||||
"appearance",
|
||||
"shortcuts",
|
||||
"account",
|
||||
"archived",
|
||||
"cli",
|
||||
];
|
||||
|
||||
interface SettingsNavItem {
|
||||
@@ -42,8 +45,14 @@ interface SettingsNavGroup {
|
||||
items: SettingsNavItem[];
|
||||
}
|
||||
|
||||
/** Nav groups for the current deploy — the Account section is auth-gated. */
|
||||
export function settingsNavGroups(accountsEnabled: boolean): SettingsNavGroup[] {
|
||||
/**
|
||||
* Nav groups for the current deploy. The Account section is auth-gated; the
|
||||
* Desktop group (Local CLI) appears only in the Electron shell.
|
||||
*/
|
||||
export function settingsNavGroups(
|
||||
accountsEnabled: boolean,
|
||||
isDesktop: boolean,
|
||||
): SettingsNavGroup[] {
|
||||
const general: SettingsNavItem[] = [
|
||||
{ id: "appearance", label: "Appearance", icon: PaletteIcon },
|
||||
{ id: "shortcuts", label: "Keyboard shortcuts", icon: KeyboardIcon, hideOnMobile: true },
|
||||
@@ -53,13 +62,23 @@ export function settingsNavGroups(accountsEnabled: boolean): SettingsNavGroup[]
|
||||
// on accounts deploys.
|
||||
general.unshift({ id: "account", label: "Account", icon: UserCogIcon });
|
||||
}
|
||||
return [
|
||||
const groups: SettingsNavGroup[] = [];
|
||||
// Desktop (Local CLI) leads when present — it's the shell-specific section a
|
||||
// desktop user is most likely here to change.
|
||||
if (isDesktop) {
|
||||
groups.push({
|
||||
title: "Desktop",
|
||||
items: [{ id: "cli", label: "Local CLI", icon: TerminalIcon }],
|
||||
});
|
||||
}
|
||||
groups.push(
|
||||
{ title: "General", items: general },
|
||||
{
|
||||
title: "Archived",
|
||||
items: [{ id: "archived", label: "Archived sessions", icon: ArchiveIcon }],
|
||||
},
|
||||
];
|
||||
);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +119,7 @@ export function SettingsSidebarBody({
|
||||
const info = useServerInfo();
|
||||
const accountsEnabled = info !== "loading" && info.accounts_enabled;
|
||||
const { section } = useSettingsRoute();
|
||||
const groups = settingsNavGroups(accountsEnabled);
|
||||
const groups = settingsNavGroups(accountsEnabled, isElectronShell());
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user