feat(coding-agent): reorganized /settings into titled sections with a sidebar layout
Also normalizes setting labels/descriptions and carries the package changelog entries for this batch (adjacent unreleased bullets are not splittable per commit), restoring section placement disturbed by the two prior changelog hunks.
This commit is contained in:
@@ -2,35 +2,46 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `ModelRegistry.resolver` now accepts a model directly — `resolver(model, sessionId)` — deriving `provider`, `baseUrl`, and `modelId` from it; all model-scoped call sites migrated from the verbose `resolver(model.provider, { sessionId, baseUrl, modelId })` form.
|
||||
|
||||
### Changed
|
||||
|
||||
- Codex, Gemini, and Perplexity web search now route their OAuth bearers through the new `withOAuthAccess` driver: a 401 or usage-limit force-refreshes the same account and then rotates to a sibling instead of failing the search, while identity metadata (`chatgpt-account-id`, Google `projectId`) is re-derived from the refreshed credential on every retry.
|
||||
- Kagi web search, the xAI TTS tool, and model-discovery list fetches now resolve their bearers through `withAuth` with an auth-storage resolver instead of a one-shot key snapshot, gaining the same force-refresh + rotate retry on 401.
|
||||
- Compaction, handoff, and branch-summarization call sites now hand the compactor a per-candidate API-key resolver (availability still gated on a key snapshot), so credential refresh happens before the #986 fallback-model loop advances.
|
||||
- The mnemopi backend now passes an OpenRouter resolver for default embedding/extraction setups (AuthStorage-stored keys included), keeping pinned literal keys and custom endpoints unchanged.
|
||||
- Reorganized the `/settings` panel for findability: every tab now has titled sections backed by a per-tab layout contract (`TAB_GROUPS`); on wide terminals the panel renders a section sidebar with the active section's settings beside it (narrow terminals keep a flat list with inline headings), and PgUp/PgDn jump section-to-section. The Editing tab became Files (edit/read/LSP) and a new Shell tab hosts bash, eval, and Python settings. Misplaced settings were rehomed: bash toggles united under Shell, tool approval mode and policies together under Interaction → Approvals, marketplace auto-update next to startup update checks, and the todo auto-clear delay beside the other todo settings.
|
||||
- Normalized `/settings` labels and descriptions: consistent Title Case labels (e.g. "Todo Auto-Clear Delay", "GitHub View Cache"), uniform unit placement, articles and verb-first phrasing in descriptions ("If false…"/"Whether to…" rewritten), and a stale browser-tool description (Ulixee Hero) corrected to the actual puppeteer/Chromium implementation.
|
||||
- Image-generation (Antigravity, xAI, OpenRouter, Gemini) and xAI TTS request failures now throw pi-ai's typed `ProviderHttpError` carrying status and response headers instead of `Object.assign`-patched `Error`s.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `lsp.formatOnWrite` sending a hardcoded `tabSize: 3, insertSpaces: true` on every `textDocument/formatting` request, which silently re-indented 2-space YAML (and any LSP-formatted file) to 3-space on every write/edit through formatter-aware servers like `yaml-language-server`. Format options are now resolved per-file from `.editorconfig` (`indent_size`, `indent_style`, `tab_width`), falling back to the indent sniffed from the in-memory content the agent is about to write, then to a 2-space default. The duplicate `DEFAULT_FORMAT_OPTIONS` constant in `lsp/index.ts` and `lsp/clients/lsp-linter-client.ts` is replaced with a single shared `resolveFormatOptions` helper ([#2329](https://github.com/can1357/oh-my-pi/issues/2329)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed stale OpenAI Responses replay failures such as `Item with id 'rs_...' not found.` by resetting the provider session and retrying without advancing fallback chains.
|
||||
- Fixed `/settings` Escape handling so an open submenu receives Esc and returns to the settings list before a second Esc closes the panel ([#2331](https://github.com/can1357/oh-my-pi/issues/2331)).
|
||||
- Fixed Escape not closing `/settings` on the Plugins tab while the async plugin list is still loading: `PluginSettingsComponent` now closes on Esc while no child view is mounted, and an npm plugin registry listing failure is caught (like marketplace failures) so a bad registry no longer leaves the tab permanently blank ([#2331](https://github.com/can1357/oh-my-pi/issues/2331)).
|
||||
- Fixed unconfigured `pi/smol`, `pi/slow`, and `pi/designer` agent model roles using cloud-priority defaults before the user's configured `modelRoles.default`, which could route local-default setups to authenticated paid providers ([#2336](https://github.com/can1357/oh-my-pi/issues/2336)).
|
||||
- Fixed `issue://` reads failing on older GitHub CLI releases that reject the optional `stateReason` issue JSON field; single issue reads now retry without it and issue listings no longer request it ([#2333](https://github.com/can1357/oh-my-pi/issues/2333)).
|
||||
- Fixed image generation ignoring `/login`-stored OpenRouter and Google API keys: provider selection and requests now resolve through the model registry (with env-var fallback) instead of environment variables only.
|
||||
- Fixed the detached (`task` async spawn) subagent shimmer animating jumpily once the parent turn ended: the shimmer phase is sampled at render time, but nothing drove redraws while the agent idled, so the band only advanced when a progress snapshot happened to repaint. Detached task blocks now run the 30fps redraw driver while they are inside the transcript live region, and freeze the moment they leave it — the driver stops, progress rows settle on static gray (instead of leaving a mid-sweep shimmer band in native scrollback), and later partial snapshots no longer repaint commit-eligible rows (the final completion snapshot still applies).
|
||||
|
||||
## [15.11.3] - 2026-06-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `issue://` reads failing on older GitHub CLI releases that reject the optional `stateReason` issue JSON field; single issue reads now retry without it and issue listings no longer request it ([#2333](https://github.com/can1357/oh-my-pi/issues/2333)).
|
||||
|
||||
## [15.11.3] - 2026-06-11
|
||||
- Fixed stale `Working…` loader rows being committed to native scrollback above the live loader: the interactive status container now reports a live-region seam while it has mounted content ([#2328](https://github.com/can1357/oh-my-pi/pull/2328) by [@35844493](https://github.com/35844493)).
|
||||
- Fixed Mnemopi memory consolidation never running on session shutdown: `MnemopiSessionState.dispose()` now drains pending fact extractions and runs `sleepAllSessions` on every owned bank before closing handles (and `AgentSession.dispose()` awaits the result), matching the `/memory enqueue` slash command. `mnemopiBackend.clear` opts out via `dispose({ consolidate: false })` so the destructive `/memory clear` path does not spend tokens consolidating memories that are wiped on the next line. `consolidate()` deliberately keeps no `aliasOf` short-circuit so `/memory enqueue` from a subagent still flushes and sleeps the parent's shared banks (the alias guard lives in `dispose` for lifecycle, not in `consolidate` for content). Without this, `episodic_memory`, `gists`, `consolidation_log`, `graph_edges`, and `triples` stayed empty for every deployment because the SHMR/beam pipeline only ran when a user typed `/memory enqueue|rebuild` ([#2320](https://github.com/can1357/oh-my-pi/issues/2320)).
|
||||
- Fixed stale OpenAI Responses replay failures such as `Item with id 'rs_...' not found.` by resetting the provider session and retrying without advancing fallback chains.
|
||||
- Fixed multi-path `search` collapsing distinct scopes into one walk rooted at their unrequested common ancestor: `paths: [".", "~/.gitconfig"]` scanned the entire home directory (typically until the 30s grep timeout), and explicit file entries inside walker-pruned directories (e.g. `.git/config`, gitignored files) silently never matched because they were folded into the directory walk's glob union. Multi-path scopes now fan out into per-target scans when the common ancestor is not itself a requested path, plain-file entries are read directly as their own targets, and matches from overlapping targets are deduplicated.
|
||||
- Fixed `lsp.formatOnWrite` sending a hardcoded `tabSize: 3, insertSpaces: true` on every `textDocument/formatting` request, which silently re-indented 2-space YAML (and any LSP-formatted file) to 3-space on every write/edit through formatter-aware servers like `yaml-language-server`. Format options are now resolved per-file from `.editorconfig` (`indent_size`, `indent_style`, `tab_width`), falling back to the indent sniffed from the in-memory content the agent is about to write, then to a 2-space default. The duplicate `DEFAULT_FORMAT_OPTIONS` constant in `lsp/index.ts` and `lsp/clients/lsp-linter-client.ts` is replaced with a single shared `resolveFormatOptions` helper ([#2329](https://github.com/can1357/oh-my-pi/issues/2329)).
|
||||
|
||||
## [15.11.2] - 2026-06-11
|
||||
|
||||
### Added
|
||||
- Fixed `issue://` reads failing on older GitHub CLI releases that reject the optional `stateReason` issue JSON field; single issue reads now retry without it and issue listings no longer request it ([#2333](https://github.com/can1357/oh-my-pi/issues/2333)).
|
||||
- Fixed image generation ignoring `/login`-stored OpenRouter and Google API keys: provider selection and requests now resolve through the model registry (with env-var fallback) instead of environment variables only.
|
||||
- Fixed the detached (`task` async spawn) subagent shimmer animating jumpily once the parent turn ended: the shimmer phase is sampled at render time, but nothing drove redraws while the agent idled, so the band only advanced when a progress snapshot happened to repaint. Detached task blocks now run the 30fps redraw driver while they are inside the transcript live region, and freeze the moment they leave it — the driver stops, progress rows settle on static gray (instead of leaving a mid-sweep shimmer band in native scrollback), and later partial snapshots no longer repaint commit-eligible rows (the final completion snapshot still applies).
|
||||
|
||||
## [15.11.3] - 2026-06-11
|
||||
|
||||
- Added the Expert Elixir language server (`expert`, invoked as `expert --stdio`) to the built-in LSP server list, auto-detected for Mix projects (`mix.exs`/`mix.lock`). When both are installed, `elixir-ls` remains the primary navigation server (Expert is ordered after it).
|
||||
- Added `magicKeywords.enabled` and per-keyword `magicKeywords.ultrathink`, `magicKeywords.orchestrate`, and `magicKeywords.workflow` settings to disable hidden magic-keyword notices and ultrathink auto-thinking escalation ([#1796](https://github.com/can1357/oh-my-pi/issues/1796)).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,8 @@
|
||||
* settings selector.
|
||||
*
|
||||
* To add a new setting to the UI: declare it in `settings-schema.ts`
|
||||
* with a `ui` block. If it needs a submenu, include `options: [...]`
|
||||
* with a `ui` block carrying `tab` and `group` (the group must be listed
|
||||
* in `TAB_GROUPS[tab]`). If it needs a submenu, include `options: [...]`
|
||||
* (or `options: "runtime"` for runtime-injected lists like themes).
|
||||
*/
|
||||
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type SettingPath,
|
||||
type SettingTab,
|
||||
type SubmenuOption,
|
||||
TAB_GROUPS,
|
||||
} from "../../config/settings-schema";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -34,6 +36,8 @@ interface BaseSettingDef {
|
||||
label: string;
|
||||
description: string;
|
||||
tab: SettingTab;
|
||||
/** Section within the tab; items are ordered by TAB_GROUPS[tab] and rendered under a heading row. */
|
||||
group?: string;
|
||||
/**
|
||||
* Optional visibility predicate. When supplied and returning false, the
|
||||
* setting is hidden from the UI. Applies to every variant — booleans,
|
||||
@@ -111,7 +115,7 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
|
||||
|
||||
const schemaType = getType(path);
|
||||
const condition = ui.condition ? CONDITIONS[ui.condition] : undefined;
|
||||
const base = { path, label: ui.label, description: ui.description, tab: ui.tab, condition };
|
||||
const base = { path, label: ui.label, description: ui.description, tab: ui.tab, group: ui.group, condition };
|
||||
|
||||
if (schemaType === "boolean") {
|
||||
return { ...base, type: "boolean" };
|
||||
@@ -170,9 +174,20 @@ export function getAllSettingDefs(): SettingDef[] {
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** Get settings for a specific tab */
|
||||
/**
|
||||
* Get settings for a specific tab, ordered by the tab's group layout
|
||||
* (TAB_GROUPS). Ungrouped settings sort first; within a group, schema
|
||||
* declaration order is preserved.
|
||||
*/
|
||||
export function getSettingsForTab(tab: SettingTab): SettingDef[] {
|
||||
return getAllSettingDefs().filter(def => def.tab === tab);
|
||||
const defs = getAllSettingDefs().filter(def => def.tab === tab);
|
||||
const order = TAB_GROUPS[tab];
|
||||
const rank = (def: SettingDef): number => {
|
||||
if (!def.group) return -1;
|
||||
const index = order.indexOf(def.group);
|
||||
return index >= 0 ? index : order.length;
|
||||
};
|
||||
return defs.sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
/** Get a setting definition by path */
|
||||
|
||||
@@ -553,12 +553,22 @@ export class SettingsSelectorComponent extends Container {
|
||||
this.addChild(this.#currentList);
|
||||
}
|
||||
|
||||
/** Map a definition list to UI items, dropping any whose condition is false. */
|
||||
/**
|
||||
* Map a definition list to UI items, dropping any whose condition is false.
|
||||
* Inserts a heading row whenever the (group-sorted) definition list crosses
|
||||
* into a new group; groups whose items are all condition-hidden emit none.
|
||||
*/
|
||||
#buildItemsForDefs(defs: SettingDef[]): SettingItem[] {
|
||||
const items: SettingItem[] = [];
|
||||
let lastGroup: string | undefined;
|
||||
for (const def of defs) {
|
||||
const item = this.#defToItem(def);
|
||||
if (item) items.push(item);
|
||||
if (!item) continue;
|
||||
if (def.group && def.group !== lastGroup) {
|
||||
items.push({ id: `__heading:${def.group}`, label: def.group, currentValue: "", heading: true });
|
||||
lastGroup = def.group;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -197,7 +197,8 @@ export type SymbolKey =
|
||||
| "tab.model"
|
||||
| "tab.interaction"
|
||||
| "tab.context"
|
||||
| "tab.editing"
|
||||
| "tab.files"
|
||||
| "tab.shell"
|
||||
| "tab.tools"
|
||||
| "tab.memory"
|
||||
| "tab.tasks"
|
||||
@@ -394,7 +395,8 @@ const UNICODE_SYMBOLS: SymbolMap = {
|
||||
"tab.model": "🤖",
|
||||
"tab.interaction": "⌨",
|
||||
"tab.context": "📋",
|
||||
"tab.editing": "💻",
|
||||
"tab.files": "📁",
|
||||
"tab.shell": "💻",
|
||||
"tab.tools": "🔧",
|
||||
"tab.memory": "🧠",
|
||||
"tab.tasks": "📦",
|
||||
@@ -693,7 +695,8 @@ const NERD_SYMBOLS: SymbolMap = {
|
||||
"tab.model": "",
|
||||
"tab.interaction": "",
|
||||
"tab.context": "",
|
||||
"tab.editing": "",
|
||||
"tab.files": "",
|
||||
"tab.shell": "",
|
||||
"tab.tools": "",
|
||||
"tab.memory": "",
|
||||
"tab.tasks": "",
|
||||
@@ -887,7 +890,8 @@ const ASCII_SYMBOLS: SymbolMap = {
|
||||
"tab.model": "[M]",
|
||||
"tab.interaction": "[I]",
|
||||
"tab.context": "[X]",
|
||||
"tab.editing": "[E]",
|
||||
"tab.files": "[F]",
|
||||
"tab.shell": "[S]",
|
||||
"tab.tools": "[T]",
|
||||
"tab.memory": "[Y]",
|
||||
"tab.tasks": "[K]",
|
||||
@@ -2678,5 +2682,8 @@ export function getSettingsListTheme(): import("@oh-my-pi/pi-tui").SettingsListT
|
||||
description: (text: string) => theme.fg("dim", text),
|
||||
cursor: theme.fg("accent", `${theme.nav.cursor} `),
|
||||
hint: (text: string) => theme.fg("dim", text),
|
||||
heading: (text: string) => theme.fg("muted", theme.bold(text)),
|
||||
section: (text: string, active: boolean) =>
|
||||
active ? theme.fg("accent", theme.bold(text)) : theme.fg("muted", text),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
SETTING_TABS,
|
||||
SETTINGS_SCHEMA,
|
||||
type SettingTab,
|
||||
TAB_GROUPS,
|
||||
} from "@oh-my-pi/pi-coding-agent/config/settings-schema";
|
||||
import { getSettingsForTab } from "@oh-my-pi/pi-coding-agent/modes/components/settings-defs";
|
||||
|
||||
interface UiShape {
|
||||
tab: SettingTab;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
describe("settings layout", () => {
|
||||
it("every UI setting declares a group registered in TAB_GROUPS for its tab", () => {
|
||||
const violations: string[] = [];
|
||||
for (const path in SETTINGS_SCHEMA) {
|
||||
const ui = (SETTINGS_SCHEMA[path as keyof typeof SETTINGS_SCHEMA] as { ui?: UiShape }).ui;
|
||||
if (!ui) continue;
|
||||
if (!ui.group) {
|
||||
violations.push(`${path}: missing ui.group`);
|
||||
} else if (!TAB_GROUPS[ui.tab].includes(ui.group)) {
|
||||
violations.push(`${path}: group "${ui.group}" not in TAB_GROUPS["${ui.tab}"]`);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("getSettingsForTab returns contiguous groups in TAB_GROUPS order", () => {
|
||||
for (const tab of SETTING_TABS) {
|
||||
const defs = getSettingsForTab(tab);
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
|
||||
// Collapse the def sequence into the order groups first appear.
|
||||
const sequence: string[] = [];
|
||||
for (const def of defs) {
|
||||
const group = def.group ?? "";
|
||||
if (sequence[sequence.length - 1] !== group) sequence.push(group);
|
||||
}
|
||||
|
||||
// Contiguous: no group appears twice in the collapsed sequence.
|
||||
expect(new Set(sequence).size).toBe(sequence.length);
|
||||
|
||||
// Ordered: grouped sections follow the TAB_GROUPS declaration order.
|
||||
const grouped = sequence.filter(group => group !== "");
|
||||
const expected = TAB_GROUPS[tab].filter(group => grouped.includes(group));
|
||||
expect(grouped).toEqual(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
+42
-6
@@ -43,8 +43,9 @@ describe("SettingsSelectorComponent memory tab", () => {
|
||||
settings.set("memory.backend", "off");
|
||||
const comp = createSelector();
|
||||
focusMemoryTab(comp);
|
||||
|
||||
const before = comp.render(120).join("\n");
|
||||
// Width 70 keeps the flat single-column layout (the wide split layout
|
||||
// shows only the active section's rows, covered by the sidebar test).
|
||||
const before = comp.render(70).join("\n");
|
||||
expect(before).toContain("Memory Backend");
|
||||
expect(before).not.toContain("Hindsight API URL");
|
||||
|
||||
@@ -56,7 +57,7 @@ describe("SettingsSelectorComponent memory tab", () => {
|
||||
comp.handleInput("\n");
|
||||
|
||||
expect(settings.get("memory.backend")).toBe("hindsight");
|
||||
const after = comp.render(120).join("\n");
|
||||
const after = comp.render(70).join("\n");
|
||||
expect(after).toContain("Memory Backend");
|
||||
expect(after).toContain("Hindsight API URL");
|
||||
expect(after).toContain("Hindsight Auto Recall");
|
||||
@@ -66,8 +67,8 @@ describe("SettingsSelectorComponent memory tab", () => {
|
||||
settings.set("memory.backend", "hindsight");
|
||||
const comp = createSelector();
|
||||
focusMemoryTab(comp);
|
||||
|
||||
expect(comp.render(120).join("\n")).toContain("Hindsight API URL");
|
||||
// Width 70 keeps the flat layout so all sections' rows render inline.
|
||||
expect(comp.render(70).join("\n")).toContain("Hindsight API URL");
|
||||
|
||||
// Open Memory Backend → SelectSubmenu pre-selects the current value
|
||||
// ("hindsight" at index 2) → step up twice to reach "off" → Enter confirms.
|
||||
@@ -77,12 +78,47 @@ describe("SettingsSelectorComponent memory tab", () => {
|
||||
comp.handleInput("\n");
|
||||
|
||||
expect(settings.get("memory.backend")).toBe("off");
|
||||
const after = comp.render(120).join("\n");
|
||||
const after = comp.render(70).join("\n");
|
||||
expect(after).toContain("Memory Backend");
|
||||
expect(after).not.toContain("Hindsight API URL");
|
||||
expect(after).not.toContain("Hindsight Auto Recall");
|
||||
});
|
||||
|
||||
it("renders group titles, suppressing groups whose items are all condition-hidden", () => {
|
||||
settings.set("memory.backend", "off");
|
||||
const comp = createSelector();
|
||||
focusMemoryTab(comp);
|
||||
|
||||
const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
|
||||
// Single visible group renders flat: the title is a standalone heading row.
|
||||
// Mnemopi/Hindsight groups are fully condition-hidden and emit nothing.
|
||||
const flatHeadings = comp
|
||||
.render(120)
|
||||
.map(line =>
|
||||
strip(line)
|
||||
.replace(/[█│]\s*$/, "")
|
||||
.trim(),
|
||||
)
|
||||
.filter(line => line === "General" || line === "Mnemopi" || line === "Hindsight");
|
||||
expect(flatHeadings).toEqual(["General"]);
|
||||
|
||||
// Switch backend to hindsight: a second group materializes, so the wide
|
||||
// render switches to the split layout with section titles in the sidebar.
|
||||
comp.handleInput("\n");
|
||||
comp.handleInput("\x1b[B");
|
||||
comp.handleInput("\x1b[B");
|
||||
comp.handleInput("\n");
|
||||
|
||||
const sidebarTitles = comp
|
||||
.render(120)
|
||||
.map(strip)
|
||||
.filter(line => line.includes("│"))
|
||||
.map(line => line.split("│")[0].trim())
|
||||
.filter(title => title.length > 0);
|
||||
expect(sidebarTitles).toEqual(["General", "Hindsight"]);
|
||||
});
|
||||
|
||||
it("clears settings search on Escape before closing the selector", () => {
|
||||
let cancelCount = 0;
|
||||
const comp = createSelector(() => {
|
||||
|
||||
Reference in New Issue
Block a user