-
Notifications
You must be signed in to change notification settings - Fork 2.2k
feat(ui): make configuration pickers compact and inline #2182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c755968
32e865c
cf53afe
fe834d5
162659e
7e64a0a
bdb6cc8
ef0c2cf
44f506d
fda835a
17a3326
1c55641
550d97f
cb7265e
4c6c64e
d0d8bf5
eb0f79c
2d27204
d07c77f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| - Changed model, provider, and MCP pickers to compact inline lists with responsive search and keyboard navigation. | ||
| - Added selected-model catalog prices for input, cached input, and output per million tokens. | ||
| - Split the configuration menu into separate single-purpose pickers and dropped the tab bar and tab navigation; each command opens only its own picker. | ||
| - Changed model rows to right-align the provider label with a require sign in hint beside it, and to list signed-in providers first with Prime Inference pinned on top when signed in. | ||
| - Added per-model effort squares to the models picker; left/right adjusts the highlighted model's reasoning level and Enter applies the model and effort together. | ||
| - Fixed the models picker search to keep signed-in providers above unsigned matches, with Prime Inference pinned on top of the signed-in group. | ||
| - Softened the selected row highlight in menu pickers: the selection background blends toward the editor surface and the selected label renders bold instead of accent-colored. | ||
| - Refined the effort squares: clusters align across rows with arrow hints on the highlighted row, spaced squares in a stronger purple, and the selected level labeled beside them. | ||
| - Folded the USD per million tokens unit into the model detail header line and left clear whitespace at the end of the detail block. | ||
| - Rounded model picker token prices to at most three decimals, showing sub-$0.001 rates as <0.001 instead of a misleading $0. | ||
| - Centered the models picker effort cluster near the row midpoint with square glyphs; fills render light gray and reserve the saturated purple for the highlighted row. | ||
| - Removed the explanatory title and subtitle lines from the model, provider, and MCP pickers; the search row now leads each picker. | ||
| - Moved the model detail pricing unit onto the price row as "dollars per 1 million tokens" and left the provider/model line bare. | ||
| - Fixed the effort cluster so changing the level never shifts the row; the level label renders in a fixed-width cell sized to the longest supported level name. | ||
| - Refined the model picker effort marks to the larger medium-square glyphs, softened the effort purple, and shortened the pricing unit to "$ / 1M tokens". | ||
| - Dropped the provider/model-id line from the inline model detail block; the prices now follow the list row directly. | ||
| - Settled the effort marks on the filled ■ and empty □ squares, the largest square pair the terminal fonts cover. | ||
| - Tightened the effort square spacing; the squares now render edge to edge and the cluster stays centered. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,9 @@ | ||
| import type { Api, Model } from "@earendil-works/pi-ai"; | ||
| import { | ||
| type Component, | ||
| Container, | ||
| type Focusable, | ||
| getKeybindings, | ||
| type TUI, | ||
| visibleWidth, | ||
| wrapTextWithAnsi, | ||
| } from "@earendil-works/pi-tui"; | ||
| import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai"; | ||
| import { Container, type Focusable, type TUI, truncateToWidth } from "@earendil-works/pi-tui"; | ||
| import type { AuthStorage } from "../../../core/auth-storage.js"; | ||
| import type { ModelRegistry } from "../../../core/model-registry.js"; | ||
| import { theme } from "../theme/theme.js"; | ||
| import { keyText } from "./keybinding-hints.js"; | ||
| import { getMenuPanelInnerWidth } from "./menu-panel.js"; | ||
| import { ModelSelectorComponent } from "./model-selector.js"; | ||
| import { type AuthSelectorProvider, OAuthSelectorComponent } from "./oauth-selector.js"; | ||
|
|
||
|
|
@@ -37,71 +28,15 @@ export interface ConfigurationMenuOptions { | |
| configuredProviders: ReadonlySet<string>; | ||
| recentModels?: ReadonlyArray<string>; | ||
| initialModelSearch?: string; | ||
| thinkingLevel?: ModelThinkingLevel; | ||
| getRows?: () => number; | ||
| requestRender: () => void; | ||
| onSelectProvider: (provider: AuthSelectorProvider) => void; | ||
| onSelectMcpConnection: (provider: AuthSelectorProvider) => void; | ||
| onSelectModel: (model: Model<Api>) => void; | ||
| onSelectModel: (model: Model<Api>, thinkingLevel?: ModelThinkingLevel) => void; | ||
| onCancel: () => void; | ||
| } | ||
|
|
||
| const TAB_LABELS: Record<ConfigurationMenuTab, string> = { | ||
| providers: "Providers", | ||
| models: "Models", | ||
| "mcp-connections": "MCP Connections", | ||
| }; | ||
|
|
||
| class ConfigurationMenuTabBar implements Component { | ||
| constructor(private readonly getActiveTab: () => ConfigurationMenuTab) {} | ||
|
|
||
| render(width: number): string[] { | ||
| return this.getLines(width); | ||
| } | ||
|
|
||
| getRowCount(width: number): number { | ||
| return this.getLines(width).length; | ||
| } | ||
|
|
||
| private getLines(width: number): string[] { | ||
| const safeWidth = Math.max(1, width); | ||
| const activeTab = this.getActiveTab(); | ||
| const labels = CONFIGURATION_MENU_TABS.map((tab) => { | ||
| const label = `[${tab === activeTab ? "▶" : " "} ${TAB_LABELS[tab]}]`; | ||
| return tab === activeTab ? theme.bold(theme.fg("accent", label)) : theme.fg("text", label); | ||
| }); | ||
| const lines = this.wrapItems( | ||
| [theme.bold(theme.fg("muted", "Tabs:")), ...labels], | ||
| theme.fg("muted", " "), | ||
| safeWidth, | ||
| ); | ||
| const tabKey = keyText("tui.input.tab", { primaryOnly: true }); | ||
| const shiftTabKey = keyText("app.configuration.previousTab", { primaryOnly: true }); | ||
| const closeKey = keyText("tui.select.cancel", { primaryOnly: true }); | ||
| const hint = `${theme.fg("dim", `${tabKey}/${shiftTabKey}`)}${theme.fg("muted", " switch tabs · ")}${theme.fg("dim", closeKey)}${theme.fg("muted", " close")}`; | ||
| return [...lines, ...wrapTextWithAnsi(hint, safeWidth)]; | ||
| } | ||
|
|
||
| private wrapItems(items: string[], separator: string, width: number): string[] { | ||
| const lines: string[] = []; | ||
| let line = ""; | ||
| for (const item of items) { | ||
| const candidate = line ? `${line}${separator}${item}` : item; | ||
| if (line && visibleWidth(candidate) > width) { | ||
| lines.push(...wrapTextWithAnsi(line, width)); | ||
| line = item; | ||
| } else { | ||
| line = candidate; | ||
| } | ||
| } | ||
| if (line) { | ||
| lines.push(...wrapTextWithAnsi(line, width)); | ||
| } | ||
| return lines; | ||
| } | ||
|
|
||
| invalidate(): void {} | ||
| } | ||
|
|
||
| export class ConfigurationMenuComponent extends Container implements Focusable { | ||
| private readonly bodies: { | ||
| providers: OAuthSelectorComponent; | ||
|
|
@@ -110,13 +45,11 @@ export class ConfigurationMenuComponent extends Container implements Focusable { | |
| }; | ||
| private activeTab: ConfigurationMenuTab; | ||
| private _focused = false; | ||
| private renderWidth = 78; | ||
|
|
||
| constructor(private readonly options: ConfigurationMenuOptions) { | ||
| super(); | ||
| this.activeTab = options.initialTab; | ||
| const tabBar = new ConfigurationMenuTabBar(() => this.activeTab); | ||
| const getHeaderRows = () => tabBar.getRowCount(getMenuPanelInnerWidth(this.renderWidth)) + 1; | ||
| const getRows = () => Math.max(1, (options.getRows?.() ?? 24) - 1); | ||
| const providerOptions = options.providerOptions.filter( | ||
| (provider) => (provider.category ?? "provider") === "provider", | ||
| ); | ||
|
|
@@ -130,11 +63,10 @@ export class ConfigurationMenuComponent extends Container implements Focusable { | |
| options.onCancel, | ||
| (providerId) => options.modelRegistry.getProviderAuthStatus(providerId), | ||
| { | ||
| getRows: options.getRows, | ||
| header: tabBar, | ||
| getHeaderRows, | ||
| title: "Providers", | ||
| subtitle: "Connect with a subscription or API key.", | ||
| getRows, | ||
| inline: true, | ||
| title: "", | ||
| subtitle: "", | ||
| searchPlaceholder: "Search providers", | ||
| }, | ||
| ); | ||
|
|
@@ -149,10 +81,10 @@ export class ConfigurationMenuComponent extends Container implements Focusable { | |
| { | ||
| availableModels: options.availableModels, | ||
| configuredProviders: options.configuredProviders, | ||
| header: tabBar, | ||
| getHeaderRows, | ||
| getRows: options.getRows, | ||
| getRows, | ||
| inline: true, | ||
| recentModels: options.recentModels, | ||
| thinkingLevel: options.thinkingLevel, | ||
| }, | ||
| ); | ||
| const mcpConnections = new OAuthSelectorComponent( | ||
|
|
@@ -163,12 +95,12 @@ export class ConfigurationMenuComponent extends Container implements Focusable { | |
| options.onCancel, | ||
| (providerId) => options.modelRegistry.getProviderAuthStatus(providerId), | ||
| { | ||
| getRows: options.getRows, | ||
| header: tabBar, | ||
| getHeaderRows, | ||
| title: "MCP Connections", | ||
| subtitle: "Connect MCP integrations and service credentials.", | ||
| getRows, | ||
| inline: true, | ||
| title: "", | ||
| subtitle: "", | ||
| searchPlaceholder: "Search MCP connections", | ||
| emptyMessage: "No MCP connections. Use /mcp add to configure a server.", | ||
| }, | ||
| ); | ||
|
|
||
|
|
@@ -190,8 +122,17 @@ export class ConfigurationMenuComponent extends Container implements Focusable { | |
| } | ||
|
|
||
| override render(width: number): string[] { | ||
| this.renderWidth = width; | ||
| return super.render(width); | ||
| const selectKey = keyText("tui.select.confirm", { primaryOnly: true }); | ||
| const closeKey = keyText("tui.select.cancel", { primaryOnly: true }); | ||
| const navigate = `${keyText("tui.select.up", { primaryOnly: true })}/${keyText("tui.select.down", { primaryOnly: true })}`; | ||
| const effort = `${keyText("tui.editor.cursorLeft", { primaryOnly: true })}/${keyText("tui.editor.cursorRight", { primaryOnly: true })}`; | ||
| const hint = | ||
| width >= 70 | ||
| ? this.activeTab === "models" | ||
| ? `${navigate} model · ${effort} effort · ${selectKey} select · ${closeKey} close` | ||
| : `${navigate} navigate · ${selectKey} select · ${closeKey} close` | ||
| : `${selectKey} select · ${closeKey} close`; | ||
| return [...super.render(width), truncateToWidth(theme.fg("dim", ` ${hint}`), width, "", true)]; | ||
| } | ||
|
|
||
| getActiveTab(): ConfigurationMenuTab { | ||
|
|
@@ -227,32 +168,10 @@ export class ConfigurationMenuComponent extends Container implements Focusable { | |
| } | ||
|
|
||
| handleInput(keyData: string): void { | ||
| const kb = getKeybindings(); | ||
| if (kb.matches(keyData, "tui.input.tab")) { | ||
| this.switchTab(1); | ||
| return; | ||
| } | ||
| if (kb.matches(keyData, "app.configuration.previousTab")) { | ||
| this.switchTab(-1); | ||
| return; | ||
| } | ||
| if ( | ||
| this.activeTab === "models" && | ||
| (kb.matches(keyData, "tui.editor.cursorLeft") || kb.matches(keyData, "tui.editor.cursorRight")) | ||
| ) { | ||
| this.activeBody.getSearchInput().handleInput(keyData); | ||
| return; | ||
| } | ||
| this.activeBody.handleInput(keyData); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium Left/right input is consumed by 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| } | ||
|
|
||
| private get activeBody(): OAuthSelectorComponent | ModelSelectorComponent { | ||
| return this.bodies[this.activeTab]; | ||
| } | ||
|
|
||
| private switchTab(direction: 1 | -1): void { | ||
| const currentIndex = CONFIGURATION_MENU_TABS.indexOf(this.activeTab); | ||
| const nextIndex = (currentIndex + direction + CONFIGURATION_MENU_TABS.length) % CONFIGURATION_MENU_TABS.length; | ||
| this.setActiveTab(CONFIGURATION_MENU_TABS[nextIndex] ?? "providers"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dead configuration tab keybinding
Low Severity
Removing tab navigation left
app.configuration.previousTabwith no non-test consumer. The exported keybinding still ships in the public map, so customkeybindings.jsonentries and Shift+Tab resolve to a no-op after the completed picker split.Triggered by project rule: Review rules
Reviewed by Cursor Bugbot for commit cf53afe. Configure here.