Merge pull request #96 from NeuralNomadsAI/dev

Release v0.9.1 - Thinking variant, Robust process cleanup
This commit is contained in:
Shantur Rathore
2026-01-25 18:08:18 +00:00
committed by GitHub
35 changed files with 681 additions and 247 deletions

View File

@@ -12,8 +12,8 @@ env:
jobs:
release-ui:
# Automated via reusable call (main releases); manual runs allowed on dev.
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' }}
# Automated via reusable call (main releases); manual runs allowed on dev/main.
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
steps:
- name: Checkout

View File

@@ -0,0 +1,7 @@
---
description: Creates release notes
agent: build
---
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.9.0",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -7384,7 +7384,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -7418,7 +7418,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -7455,14 +7455,14 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.9.0",
"version": "0.9.1",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.8.1",
"minServerVersion": "0.9.1",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -177,8 +177,11 @@ export class CliProcessManager extends EventEmitter {
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
console.warn(
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
)
child.kill("SIGKILL")
}, 4000)
}, 30000)
child.on("exit", () => {
clearTimeout(killTimeout)
@@ -376,4 +379,3 @@ export class CliProcessManager extends EventEmitter {
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.0",
"version": "0.9.1",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.9.0",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.9.0",
"version": "0.9.1",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",

View File

@@ -15,6 +15,7 @@ const PreferencesSchema = z.object({
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),

View File

@@ -286,21 +286,33 @@ async function main() {
return
}
shuttingDown = true
logger.info("Received shutdown signal, closing server")
try {
await server.stop()
logger.info("HTTP server stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}
logger.info("Received shutdown signal, stopping workspaces and server")
try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
const shutdownWorkspaces = (async () => {
try {
instanceEventBridge.shutdown()
} catch (error) {
logger.warn({ err: error }, "Instance event bridge shutdown failed")
}
try {
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
})()
const shutdownHttp = (async () => {
try {
await server.stop()
logger.info("HTTP server stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}
})()
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
// no-op: remote UI manifest replaces GitHub release monitor

View File

@@ -187,16 +187,27 @@ export class WorkspaceManager {
async shutdown() {
this.options.logger.info("Shutting down all workspaces")
const stopTasks: Array<Promise<void>> = []
for (const [id, workspace] of this.workspaces) {
if (workspace.pid) {
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
await this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
})
} else {
if (!workspace.pid) {
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
continue
}
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
stopTasks.push(
this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
}),
)
}
if (stopTasks.length > 0) {
await Promise.allSettled(stopTasks)
}
this.workspaces.clear()
this.opencodeAuth.clear()
this.options.logger.info("All workspaces cleared")

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"scripts": {
"dev": "tauri dev",

View File

@@ -34,6 +34,8 @@ fn workspace_root() -> Option<PathBuf> {
const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
let mut display = url.to_string();
@@ -276,6 +278,7 @@ impl CliProcessManager {
pub fn stop(&self) -> anyhow::Result<()> {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
@@ -290,7 +293,12 @@ impl CliProcessManager {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > Duration::from_secs(4) {
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
log_line(&format!(
"stop timed out after {}s; sending SIGKILL pid={}",
CLI_STOP_GRACE_SECS,
child.id()
));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);

View File

@@ -163,7 +163,8 @@ fn main() {
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { .. } => {
tauri::RunEvent::ExitRequested { api, .. } => {
api.prevent_exit();
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
@@ -173,18 +174,18 @@ fn main() {
});
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
// Ensure we have time to stop the CLI process before the app exits.
api.prevent_close();
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
_ => {}
});

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
@@ -94,6 +95,7 @@ const App: Component = () => {
})
onMount(() => {
void initGithubStars()
updateInstanceTabBarHeight()
const handleResize = () => updateInstanceTabBarHeight()
window.addEventListener("resize", handleResize)

View File

@@ -4,6 +4,7 @@ import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import { getLogger } from "../lib/logger"
import Kbd from "./kbd"
const log = getLogger("session")
@@ -99,15 +100,20 @@ export default function AgentSelector(props: AgentSelectorProps) {
data-agent-selector
class="selector-trigger"
>
<Select.Value<Agent>>
{(state) => (
<div class="selector-trigger-label">
<span class="selector-trigger-primary">
Agent: {state.selectedOption()?.name ?? "None"}
</span>
</div>
)}
</Select.Value>
<div class="flex-1 min-w-0">
<Select.Value<Agent>>
{(state) => (
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
Agent: {state.selectedOption()?.name ?? "None"}
</span>
</div>
)}
</Select.Value>
</div>
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
<Kbd shortcut="cmd+shift+a" />
</span>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>

View File

@@ -0,0 +1,38 @@
import type { Component } from "solid-js"
type BrandIconProps = {
class?: string
title?: string
}
export const GitHubMarkIcon: Component<BrandIconProps> = (props) => (
<svg
viewBox="0 0 98 96"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={props.title ? undefined : "true"}
role={props.title ? "img" : "presentation"}
class={props.class}
>
{props.title ? <title>{props.title}</title> : null}
<path
fill="currentColor"
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
/>
</svg>
)
export const DiscordSymbolIcon: Component<BrandIconProps> = (props) => (
<svg
viewBox="0 0 64 48"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={props.title ? undefined : "true"}
role={props.title ? "img" : "presentation"}
class={props.class}
>
{props.title ? <title>{props.title}</title> : null}
<path
fill="currentColor"
d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z"
/>
</svg>
)

View File

@@ -1,11 +1,14 @@
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
import { githubStars } from "../stores/github-stars"
import { formatCompactCount } from "../lib/formatters"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -188,6 +191,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isLoading()) return
props.onSelectFolder(path, selectedBinary())
}
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() {
if (isLoading()) return
@@ -242,170 +250,228 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
style="background-color: var(--surface-secondary)"
>
<div
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<button
type="button"
class="selector-button selector-button-secondary inline-flex items-center justify-center"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
<div class="mt-2 flex justify-center">
<VersionPill />
</div>
</div>
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">No Recent Folders</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Recent Folders</h2>
<p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
</p>
</div>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent"
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button
onClick={() => props.onAdvancedSettingsOpen?.()}
class="panel-section-header w-full justify-between"
>
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
</div>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2">
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label="CodeNomad GitHub"
title="CodeNomad GitHub"
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}}
>
<GitHubMarkIcon class="w-4 h-4" />
</a>
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label="CodeNomad GitHub Stars"
title="CodeNomad GitHub Stars"
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}}
>
<Star class="w-4 h-4" />
<Show when={githubStars() !== null}>
<span class="text-xs font-medium">{formatCompactCount(githubStars()!)}</span>
</Show>
</a>
<a
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label="CodeNomad Discord"
title="CodeNomad Discord"
onClick={(event) => {
event.preventDefault()
openExternalLink(
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
)
}}
>
<DiscordSymbolIcon class="w-4 h-4" />
</a>
</div>
<p class="mt-3 text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>Remove</span>
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
{/* Right column: recent folders */}
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">No Recent Folders</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Recent Folders</h2>
<p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
</p>
</div>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent"
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>Browse</span>
</div>
{/* Left column: version + browse + advanced settings */}
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
</div>
</div>
<div class="panel shrink-0">
<div class="panel-body flex items-center justify-center">
<VersionPill />
</div>
</div>
</div>
</div>
<div class="panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>Remove</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>Browse</span>
</div>
</div>
</div>
</div>

View File

@@ -48,15 +48,16 @@ import { clearSessionRenderCache } from "../message-block"
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint"
import Kbd from "../kbd"
import InstanceWelcomeView from "../instance-welcome-view"
import InfoView from "../info-view"
import InstanceServiceStatus from "../instance-service-status"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import ThinkingSelector from "../thinking-selector"
import CommandPalette from "../command-palette"
import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal"
import Kbd from "../kbd"
import { TodoListView } from "../tool-call/renderers/todo"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
@@ -432,6 +433,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return true
}
const focusVariantSelectorControl = () => {
const input = leftDrawerContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
if (!input) return false
input.focus()
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
return true
}
createEffect(() => {
const pending = pendingSidebarAction()
if (!pending) return
@@ -444,7 +453,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
setPendingSidebarAction(null)
return
}
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl()
const handled =
action === "focus-agent-selector"
? focusAgentSelectorControl()
: action === "focus-model-selector"
? focusModelSelectorControl()
: focusVariantSelectorControl()
if (handled) {
setPendingSidebarAction(null)
}
@@ -901,21 +915,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<div class="sidebar-selector-hints" aria-hidden="true">
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
<ModelSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
</div>
</>
)}

View File

@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session"
import { getLogger } from "../lib/logger"
import Kbd from "./kbd"
const log = getLogger("session")
@@ -105,7 +106,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
ref={triggerRef}
class="selector-trigger"
>
<div class="selector-trigger-label selector-trigger-label--stacked">
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
Model: {currentModelValue()?.name ?? "None"}
</span>
@@ -115,6 +116,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
</span>
)}
</div>
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
<Kbd shortcut="cmd+shift+m" />
</span>
<Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>

View File

@@ -0,0 +1,107 @@
import { Combobox } from "@kobalte/core/combobox"
import { createEffect, createMemo } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import { getLogger } from "../lib/logger"
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
import Kbd from "./kbd"
const log = getLogger("session")
interface ThinkingSelectorProps {
instanceId: string
currentModel: { providerId: string; modelId: string }
}
type ThinkingOption = {
key: string
label: string
value: string | undefined
}
export default function ThinkingSelector(props: ThinkingSelectorProps) {
const instanceProviders = () => providers().get(props.instanceId) || []
createEffect(() => {
if (instanceProviders().length === 0) {
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
}
})
const variantKeys = createMemo(() => {
const { providerId, modelId } = props.currentModel
const provider = instanceProviders().find((p) => p.id === providerId)
const model = provider?.models.find((m) => m.id === modelId)
return model?.variantKeys ?? []
})
const options = createMemo<ThinkingOption[]>(() => {
const keys = variantKeys()
return [{ key: "__default__", label: "Default", value: undefined }, ...keys.map((k) => ({ key: k, label: k, value: k }))]
})
const currentValue = createMemo(() => {
const selected = getModelThinkingSelection(props.currentModel)
const keys = variantKeys()
if (selected && keys.includes(selected)) {
return options().find((opt) => opt.value === selected)
}
return options()[0]
})
const handleChange = (value: ThinkingOption | null) => {
if (!value) return
setModelThinkingSelection(props.currentModel, value.value)
}
const triggerPrimary = createMemo(() => {
const selected = currentValue()?.value
return selected ? `Thinking: ${selected}` : "Thinking: Default"
})
return (
<div class="sidebar-selector">
<Combobox<ThinkingOption>
value={currentValue()}
onChange={handleChange}
options={options()}
optionValue="key"
optionLabel="label"
placeholder="Thinking: Default"
itemComponent={(itemProps) => (
<Combobox.Item item={itemProps.item} class="selector-option">
<div class="selector-option-content">
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Combobox.ItemLabel>
</div>
<Combobox.ItemIndicator class="selector-option-indicator">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
)}
>
<Combobox.Control class="relative w-full" data-thinking-selector-control>
<Combobox.Input class="sr-only" data-thinking-selector />
<Combobox.Trigger class="selector-trigger">
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
</div>
<span class="selector-trigger-hint" aria-hidden="true">
<Kbd shortcut="cmd+shift+t" />
</span>
<Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>
</Combobox.Trigger>
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class="selector-popover">
<Combobox.Listbox class="selector-listbox" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>
</div>
)
}

View File

@@ -10,3 +10,20 @@ export function formatTokenTotal(value: number): string {
}
return value.toLocaleString()
}
export function formatCompactCount(value: number): string {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(1)}B`
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}
if (value >= 10_000) {
return `${Math.round(value / 1_000)}K`
}
if (value >= 1_000) {
const label = `${(value / 1_000).toFixed(1)}K`
return label.replace(/\.0K$/, "K")
}
return value.toLocaleString()
}

View File

@@ -69,6 +69,11 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
},
() => {
const instance = options.getActiveInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
},
)
registerEscapeShortcut(

View File

@@ -374,6 +374,20 @@ export function useCommands(options: UseCommandsOptions) {
},
})
commandRegistry.register({
id: "open-variant-selector",
label: "Select Model Variant",
description: "Choose a thinking effort for the current model",
category: "Agent & Model",
keywords: ["variant", "thinking", "reasoning", "effort"],
shortcut: { key: "T", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
},
})
commandRegistry.register({
id: "open-agent-selector",
label: "Open Agent Selector",

View File

@@ -1,4 +1,8 @@
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
export type SessionSidebarRequestAction =
| "focus-agent-selector"
| "focus-model-selector"
| "focus-variant-selector"
| "show-session-list"
export interface SessionSidebarRequestDetail {
instanceId: string

View File

@@ -1,6 +1,10 @@
import { keyboardRegistry } from "../keyboard-registry"
export function registerAgentShortcuts(focusModelSelector: () => void, openAgentSelector: () => void) {
export function registerAgentShortcuts(
focusModelSelector: () => void,
openAgentSelector: () => void,
focusVariantSelector: () => void,
) {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({
@@ -20,4 +24,13 @@ export function registerAgentShortcuts(focusModelSelector: () => void, openAgent
description: "open agent",
context: "global",
})
keyboardRegistry.register({
id: "focus-variant",
key: "T",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: focusVariantSelector,
description: "focus thinking",
context: "global",
})
}

View File

@@ -0,0 +1,54 @@
import { createSignal } from "solid-js"
import { getLogger } from "../lib/logger"
const log = getLogger("api")
const STORAGE_KEY = "codenomad:github:stars"
const REPO_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad"
function readStoredStars(): number | null {
if (typeof window === "undefined") return null
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const value = Number(raw)
if (!Number.isFinite(value) || value < 0) return null
return Math.floor(value)
}
function storeStars(value: number): void {
if (typeof window === "undefined") return
window.localStorage.setItem(STORAGE_KEY, String(value))
}
const [githubStars, setGithubStars] = createSignal<number | null>(readStoredStars())
let initialized = false
export async function initGithubStars(): Promise<void> {
if (initialized) return
initialized = true
try {
const response = await fetch(REPO_API_URL, {
headers: {
Accept: "application/vnd.github+json",
},
})
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}`)
}
const data = (await response.json()) as { stargazers_count?: unknown }
const next = typeof data.stargazers_count === "number" ? data.stargazers_count : null
if (next === null || !Number.isFinite(next) || next < 0) {
return
}
const normalized = Math.floor(next)
setGithubStars(normalized)
storeStars(normalized)
} catch (error) {
log.warn("Failed to fetch GitHub stars", error)
}
}
export { githubStars }

View File

@@ -39,6 +39,7 @@ export interface Preferences {
lastUsedBinary?: string
environmentVariables: Record<string, string>
modelRecents: ModelPreference[]
modelThinkingSelections: Record<string, string>
diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference
@@ -71,6 +72,7 @@ const defaultPreferences: Preferences = {
showTimelineTools: true,
environmentVariables: {},
modelRecents: [],
modelThinkingSelections: {},
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
@@ -102,6 +104,11 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
const modelThinkingSelections = {
...defaultPreferences.modelThinkingSelections,
...(sanitized.modelThinkingSelections ?? {}),
}
return {
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
@@ -109,6 +116,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
environmentVariables,
modelRecents,
modelThinkingSelections,
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
@@ -118,6 +126,35 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
}
}
function getModelKey(model: { providerId: string; modelId: string }): string {
return `${model.providerId}/${model.modelId}`
}
function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined {
if (!model.providerId || !model.modelId) return undefined
return preferences().modelThinkingSelections?.[getModelKey(model)]
}
function setModelThinkingSelection(model: { providerId: string; modelId: string }, value: string | undefined): void {
if (!model.providerId || !model.modelId) return
const key = getModelKey(model)
const current = preferences().modelThinkingSelections?.[key]
if (current === value) return
updateConfig((draft) => {
const selections = { ...(draft.preferences.modelThinkingSelections ?? {}) }
if (!value) {
delete selections[key]
} else {
selections[key] = value
}
draft.preferences = normalizePreferences({
...draft.preferences,
modelThinkingSelections: selections,
})
})
}
const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
@@ -527,6 +564,8 @@ export {
addEnvironmentVariable,
removeEnvironmentVariable,
addRecentModelPreference,
getModelThinkingSelection,
setModelThinkingSelection,
setAgentModelPreference,
getAgentModelPreference,
setDiffViewMode,
@@ -540,4 +579,3 @@ export {
}

View File

@@ -1,8 +1,8 @@
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances"
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
import { sessions, withSession } from "./session-state"
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
import { providers, sessions, withSession } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models"
import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus"
@@ -11,6 +11,22 @@ import { requestData } from "../lib/opencode-api"
const log = getLogger("actions")
function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] {
if (!model.providerId || !model.modelId) return []
const instanceProviders = providers().get(instanceId) || []
const provider = instanceProviders.find((p) => p.id === model.providerId)
const match = provider?.models.find((m) => m.id === model.modelId)
return match?.variantKeys ?? []
}
function getThinkingVariantToSend(instanceId: string, model: { providerId: string; modelId: string }): string | undefined {
const selected = getModelThinkingSelection(model)
if (!selected) return undefined
const keys = getVariantKeysForModel(instanceId, model)
if (keys.length === 0) return undefined
return keys.includes(selected) ? selected : undefined
}
const ID_LENGTH = 26
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
@@ -170,6 +186,12 @@ async function sendMessage(
modelID: session.model.modelId,
},
}),
...(session.model.providerId &&
session.model.modelId &&
(() => {
const variant = getThinkingVariantToSend(instanceId, session.model)
return variant ? { variant } : {}
})()),
}
log.info("sendMessage", {
@@ -215,6 +237,7 @@ async function executeCustomCommand(
messageID: string
agent?: string
model?: string
variant?: string
} = {
command: commandName,
arguments: args,
@@ -227,6 +250,8 @@ async function executeCustomCommand(
if (session.model.providerId && session.model.modelId) {
body.model = `${session.model.providerId}/${session.model.modelId}`
const variant = getThinkingVariantToSend(instanceId, session.model)
if (variant) body.variant = variant
}
await requestData(

View File

@@ -483,6 +483,7 @@ async function fetchProviders(instanceId: string): Promise<void> {
providerId: provider.id,
limit: model.limit,
cost: model.cost,
variantKeys: Object.keys(model.variants ?? {}),
})),
}))

View File

@@ -27,6 +27,10 @@
@apply items-start;
}
.selector-trigger-label--top {
@apply self-start;
}
.selector-trigger-primary {
@apply text-sm font-medium truncate;
color: var(--text-primary);
@@ -46,6 +50,15 @@
color: var(--text-muted);
}
.selector-trigger-hint {
@apply flex-shrink-0;
color: var(--text-muted);
}
.selector-trigger-hint--top {
@apply self-start mt-0.5;
}
.selector-popover {
@apply rounded-md shadow-lg overflow-hidden min-w-[300px];
background-color: var(--surface-base);

View File

@@ -115,29 +115,6 @@ session-sidebar-controls .selector-trigger-primary {
@apply flex flex-col gap-1 w-full;
}
.sidebar-selector-hint {
@apply flex justify-center text-xs w-full;
color: var(--text-muted);
}
.sidebar-selector-hints {
@apply flex items-center gap-2 w-full;
justify-content: space-between;
}
.sidebar-selector-hint--left,
.sidebar-selector-hint--right {
@apply flex-1;
}
.sidebar-selector-hint--left {
justify-content: flex-start;
}
.sidebar-selector-hint--right {
justify-content: flex-end;
}
.session-header-hints {
@apply flex-shrink-0;
}

View File

@@ -85,6 +85,7 @@ export interface Model {
id: string
name: string
providerId: string
variantKeys?: string[]
limit?: {
context?: number
output?: number