Compare commits

...

12 Commits

Author SHA1 Message Date
Raunak Bhagat
a3dfe6aa1b refactor(opal): unify Interactive color system (#9717) 2026-03-28 00:40:23 +00:00
Nikolas Garza
23e4d55fb1 perf(swr): convert raw-fetch hooks to SWR to eliminate duplicate requests (#9694) 2026-03-28 00:26:20 +00:00
Jamison Lahman
470cc85f83 feat(cli): onyx-cli serve over SSH (#9726) 2026-03-27 23:46:14 +00:00
Justin Tahara
64d9be5a41 fix(openpyxl): Colors must be aRGB hex values (#9727) 2026-03-27 23:14:36 +00:00
roshan
71a5b469b0 feat(widget): add citation badges to chat widget (#9714) 2026-03-27 22:39:46 +00:00
Evan Lohn
462eb0697f fix: Anthropic litellm thinking workaround (#9713) 2026-03-27 21:03:05 +00:00
dependabot[bot]
b708dc8796 chore(deps): bump langchain-core from 1.2.11 to 1.2.22 (#9720)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-27 20:50:19 +00:00
dependabot[bot]
c9e2c32f55 chore(deps): bump cryptography from 46.0.5 to 46.0.6 (#9721)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-27 20:48:59 +00:00
Jamison Lahman
d725df62e7 feat(cli): --version and validate-config warn if backend version is incompatible (#9715) 2026-03-27 13:13:16 -07:00
Jamison Lahman
d1460972b6 fix(cli): onyx-cli --version interpolation (#9712) 2026-03-27 19:22:31 +00:00
Jamison Lahman
706872f0b7 chore(deps): upgrade go deps (#9711) 2026-03-27 12:24:25 -07:00
Jamison Lahman
ed3856be2b chore(release): build all CLI wheels before publishing (#9710) 2026-03-27 19:04:02 +00:00
72 changed files with 2018 additions and 745 deletions

View File

@@ -13,15 +13,6 @@ jobs:
permissions:
id-token: write
timeout-minutes: 10
strategy:
matrix:
os-arch:
- { goos: "linux", goarch: "amd64" }
- { goos: "linux", goarch: "arm64" }
- { goos: "windows", goarch: "amd64" }
- { goos: "windows", goarch: "arm64" }
- { goos: "darwin", goarch: "amd64" }
- { goos: "darwin", goarch: "arm64" }
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
@@ -31,9 +22,11 @@ jobs:
enable-cache: false
version: "0.9.9"
- run: |
GOOS="${{ matrix.os-arch.goos }}" \
GOARCH="${{ matrix.os-arch.goarch }}" \
uv build --wheel
for goos in linux windows darwin; do
for goarch in amd64 arm64; do
GOOS="$goos" GOARCH="$goarch" uv build --wheel
done
done
working-directory: cli
- run: uv publish
working-directory: cli

View File

@@ -122,7 +122,7 @@ repos:
rev: 5d1e709b7be35cb2025444e19de266b056b7b7ee # frozen: v2.10.1
hooks:
- id: golangci-lint
language_version: "1.26.0"
language_version: "1.26.1"
entry: bash -c "find . -name go.mod -not -path './.venv/*' -print0 | xargs -0 -I{} bash -c 'cd \"$(dirname {})\" && golangci-lint run ./...'"
- repo: https://github.com/astral-sh/ruff-pre-commit

View File

@@ -44,6 +44,7 @@ KNOWN_OPENPYXL_BUGS = [
"Value must be either numerical or a string containing a wildcard",
"File contains no valid workbook part",
"Unable to read workbook: could not read stylesheet from None",
"Colors must be aRGB hex values",
]

View File

@@ -185,6 +185,21 @@ def _messages_contain_tool_content(messages: list[dict[str, Any]]) -> bool:
return False
def _prompt_contains_tool_call_history(prompt: LanguageModelInput) -> bool:
"""Check if the prompt contains any assistant messages with tool_calls.
When Anthropic's extended thinking is enabled, the API requires every
assistant message to start with a thinking block before any tool_use
blocks. Since we don't preserve thinking_blocks (they carry
cryptographic signatures that can't be reconstructed), we must skip
the thinking param whenever history contains prior tool-calling turns.
"""
from onyx.llm.models import AssistantMessage
msgs = prompt if isinstance(prompt, list) else [prompt]
return any(isinstance(msg, AssistantMessage) and msg.tool_calls for msg in msgs)
def _is_vertex_model_rejecting_output_config(model_name: str) -> bool:
normalized_model_name = model_name.lower()
return any(
@@ -466,7 +481,20 @@ class LitellmLLM(LLM):
reasoning_effort
)
if budget_tokens is not None:
# Anthropic requires every assistant message with tool_use
# blocks to start with a thinking block that carries a
# cryptographic signature. We don't preserve those blocks
# across turns, so skip thinking when the history already
# contains tool-calling assistant messages. LiteLLM's
# modify_params workaround doesn't cover all providers
# (notably Bedrock).
can_enable_thinking = (
budget_tokens is not None
and not _prompt_contains_tool_call_history(prompt)
)
if can_enable_thinking:
assert budget_tokens is not None # mypy
if max_tokens is not None:
# Anthropic has a weird rule where max token has to be at least as much as budget tokens if set
# and the minimum budget tokens is 1024

View File

@@ -187,7 +187,7 @@ coloredlogs==15.0.1
# via onnxruntime
courlan==1.3.2
# via trafilatura
cryptography==46.0.5
cryptography==46.0.6
# via
# authlib
# google-auth
@@ -449,7 +449,7 @@ kombu==5.5.4
# via celery
kubernetes==31.0.0
# via onyx
langchain-core==1.2.11
langchain-core==1.2.22
# via onyx
langdetect==1.0.9
# via unstructured

View File

@@ -97,7 +97,7 @@ comm==0.2.3
# via ipykernel
contourpy==1.3.3
# via matplotlib
cryptography==46.0.5
cryptography==46.0.6
# via
# google-auth
# pyjwt

View File

@@ -76,7 +76,7 @@ colorama==0.4.6 ; sys_platform == 'win32'
# via
# click
# tqdm
cryptography==46.0.5
cryptography==46.0.6
# via
# google-auth
# pyjwt

View File

@@ -92,7 +92,7 @@ colorama==0.4.6 ; sys_platform == 'win32'
# via
# click
# tqdm
cryptography==46.0.5
cryptography==46.0.6
# via
# google-auth
# pyjwt

View File

@@ -11,6 +11,7 @@ from litellm.types.utils import ChatCompletionDeltaToolCall
from litellm.types.utils import Delta
from litellm.types.utils import Function as LiteLLMFunction
import onyx.llm.models
from onyx.configs.app_configs import MOCK_LLM_RESPONSE
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import LLMUserIdentity
@@ -1479,6 +1480,147 @@ def test_bifrost_normalizes_api_base_in_model_kwargs() -> None:
assert llm._model_kwargs["api_base"] == "https://bifrost.example.com/v1"
def test_prompt_contains_tool_call_history_true() -> None:
from onyx.llm.multi_llm import _prompt_contains_tool_call_history
messages: LanguageModelInput = [
UserMessage(content="What's the weather?"),
AssistantMessage(
content=None,
tool_calls=[
ToolCall(
id="tc_1",
function=FunctionCall(name="get_weather", arguments="{}"),
)
],
),
]
assert _prompt_contains_tool_call_history(messages) is True
def test_prompt_contains_tool_call_history_false_no_tools() -> None:
from onyx.llm.multi_llm import _prompt_contains_tool_call_history
messages: LanguageModelInput = [
UserMessage(content="Hello"),
AssistantMessage(content="Hi there!"),
]
assert _prompt_contains_tool_call_history(messages) is False
def test_prompt_contains_tool_call_history_false_user_only() -> None:
from onyx.llm.multi_llm import _prompt_contains_tool_call_history
messages: LanguageModelInput = [UserMessage(content="Hello")]
assert _prompt_contains_tool_call_history(messages) is False
def test_bedrock_claude_drops_thinking_when_thinking_blocks_missing() -> None:
"""When thinking is enabled but assistant messages with tool_calls lack
thinking_blocks, the thinking param must be dropped to avoid the Bedrock
BadRequestError about missing thinking blocks."""
llm = LitellmLLM(
api_key=None,
timeout=30,
model_provider=LlmProviderNames.BEDROCK,
model_name="anthropic.claude-sonnet-4-20250514-v1:0",
max_input_tokens=200000,
)
messages: LanguageModelInput = [
UserMessage(content="What's the weather?"),
AssistantMessage(
content=None,
tool_calls=[
ToolCall(
id="tc_1",
function=FunctionCall(
name="get_weather",
arguments='{"city": "Paris"}',
),
)
],
),
onyx.llm.models.ToolMessage(
content="22°C sunny",
tool_call_id="tc_1",
),
]
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
]
with (
patch("litellm.completion") as mock_completion,
patch("onyx.llm.multi_llm.model_is_reasoning_model", return_value=True),
):
mock_completion.return_value = []
list(llm.stream(messages, tools=tools, reasoning_effort=ReasoningEffort.HIGH))
kwargs = mock_completion.call_args.kwargs
assert "thinking" not in kwargs, (
"thinking param should be dropped when thinking_blocks are missing "
"from assistant messages with tool_calls"
)
def test_bedrock_claude_keeps_thinking_when_no_tool_history() -> None:
"""When thinking is enabled and there are no historical assistant messages
with tool_calls, the thinking param should be preserved."""
llm = LitellmLLM(
api_key=None,
timeout=30,
model_provider=LlmProviderNames.BEDROCK,
model_name="anthropic.claude-sonnet-4-20250514-v1:0",
max_input_tokens=200000,
)
messages: LanguageModelInput = [
UserMessage(content="What's the weather?"),
]
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
]
with (
patch("litellm.completion") as mock_completion,
patch("onyx.llm.multi_llm.model_is_reasoning_model", return_value=True),
):
mock_completion.return_value = []
list(llm.stream(messages, tools=tools, reasoning_effort=ReasoningEffort.HIGH))
kwargs = mock_completion.call_args.kwargs
assert "thinking" in kwargs, (
"thinking param should be preserved when no assistant messages "
"with tool_calls exist in history"
)
assert kwargs["thinking"]["type"] == "enabled"
def test_bifrost_claude_includes_allowed_openai_params() -> None:
llm = LitellmLLM(
api_key="test_key",

1
cli/.gitignore vendored
View File

@@ -1,3 +1,4 @@
onyx-cli
cli
onyx.cli
__pycache__

View File

@@ -63,6 +63,31 @@ onyx-cli agents
onyx-cli agents --json
```
### Serve over SSH
```shell
# Start a public SSH endpoint for the CLI TUI
onyx-cli serve --host 0.0.0.0 --port 2222
# Connect as a client
ssh your-host -p 2222
```
Clients can either:
- paste an API key at the login prompt, or
- skip the prompt by sending `ONYX_API_KEY` over SSH:
```shell
export ONYX_API_KEY=your-key
ssh -o SendEnv=ONYX_API_KEY your-host -p 2222
```
Useful hardening flags:
- `--idle-timeout` (default `15m`)
- `--max-session-timeout` (default `8h`)
- `--rate-limit-per-minute` (default `20`)
- `--rate-limit-burst` (default `40`)
## Commands
| Command | Description |
@@ -70,6 +95,7 @@ onyx-cli agents --json
| `chat` | Launch the interactive chat TUI (default) |
| `ask` | Ask a one-shot question (non-interactive) |
| `agents` | List available agents |
| `serve` | Serve the interactive chat TUI over SSH |
| `configure` | Configure server URL and API key |
| `validate-config` | Validate configuration and test connection |

View File

@@ -1,7 +1,17 @@
// Package cmd implements Cobra CLI commands for the Onyx CLI.
package cmd
import "github.com/spf13/cobra"
import (
"context"
"fmt"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/version"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
// Version and Commit are set via ldflags at build time.
var (
@@ -16,15 +26,69 @@ func fullVersion() string {
return Version
}
func printVersion(cmd *cobra.Command) {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Client version: %s\n", fullVersion())
cfg := config.Load()
if !cfg.IsConfigured() {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server version: unknown (not configured)\n")
return
}
client := api.NewClient(cfg)
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
log.Debug("fetching backend version from /api/version")
backendVersion, err := client.GetBackendVersion(ctx)
if err != nil {
log.WithError(err).Debug("could not fetch backend version")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server version: unknown (could not reach server)\n")
return
}
if backendVersion == "" {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server version: unknown (empty response)\n")
return
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server version: %s\n", backendVersion)
min := version.MinServer()
if sv, ok := version.Parse(backendVersion); ok && sv.LessThan(min) {
log.Warnf("Server version %s is below minimum required %d.%d, please upgrade",
backendVersion, min.Major, min.Minor)
}
}
// Execute creates and runs the root command.
func Execute() error {
opts := struct {
Debug bool
}{}
rootCmd := &cobra.Command{
Use: "onyx-cli",
Short: "Terminal UI for chatting with Onyx",
Long: "Onyx CLI — a terminal interface for chatting with your Onyx agent.",
Version: fullVersion(),
Use: "onyx-cli",
Short: "Terminal UI for chatting with Onyx",
Long: "Onyx CLI — a terminal interface for chatting with your Onyx agent.",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if opts.Debug {
log.SetLevel(log.DebugLevel)
} else {
log.SetLevel(log.InfoLevel)
}
log.SetFormatter(&log.TextFormatter{
DisableTimestamp: true,
})
},
}
rootCmd.PersistentFlags().BoolVar(&opts.Debug, "debug", false, "run in debug mode")
// Custom --version flag instead of Cobra's built-in (which only shows one version string)
var showVersion bool
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Print client and server version information")
// Register subcommands
chatCmd := newChatCmd()
rootCmd.AddCommand(chatCmd)
@@ -32,9 +96,16 @@ func Execute() error {
rootCmd.AddCommand(newAgentsCmd())
rootCmd.AddCommand(newConfigureCmd())
rootCmd.AddCommand(newValidateConfigCmd())
rootCmd.AddCommand(newServeCmd())
// Default command is chat
rootCmd.RunE = chatCmd.RunE
// Default command is chat, but intercept --version first
rootCmd.RunE = func(cmd *cobra.Command, args []string) error {
if showVersion {
printVersion(cmd)
return nil
}
return chatCmd.RunE(cmd, args)
}
return rootCmd.Execute()
}

450
cli/cmd/serve.go Normal file
View File

@@ -0,0 +1,450 @@
package cmd
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/activeterm"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"github.com/charmbracelet/wish/ratelimiter"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/tui"
"github.com/spf13/cobra"
"golang.org/x/time/rate"
)
const (
defaultServeIdleTimeout = 15 * time.Minute
defaultServeMaxSessionTimeout = 8 * time.Hour
defaultServeRateLimitPerMinute = 20
defaultServeRateLimitBurst = 40
defaultServeRateLimitCacheSize = 4096
maxAPIKeyLength = 512
apiKeyValidationTimeout = 15 * time.Second
maxAPIKeyRetries = 5
)
func sessionEnv(s ssh.Session, key string) string {
prefix := key + "="
for _, env := range s.Environ() {
if strings.HasPrefix(env, prefix) {
return env[len(prefix):]
}
}
return ""
}
func validateAPIKey(serverURL string, apiKey string) error {
trimmedKey := strings.TrimSpace(apiKey)
if len(trimmedKey) > maxAPIKeyLength {
return fmt.Errorf("API key is too long (max %d characters)", maxAPIKeyLength)
}
cfg := config.OnyxCliConfig{
ServerURL: serverURL,
APIKey: trimmedKey,
}
client := api.NewClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), apiKeyValidationTimeout)
defer cancel()
return client.TestConnection(ctx)
}
// --- auth prompt (bubbletea model) ---
type authState int
const (
authInput authState = iota
authValidating
authDone
)
type authValidatedMsg struct {
key string
err error
}
type authModel struct {
input textinput.Model
serverURL string
state authState
apiKey string // set on successful validation
errMsg string
retries int
aborted bool
}
func newAuthModel(serverURL, initialErr string) authModel {
ti := textinput.New()
ti.Prompt = " API Key: "
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
ti.CharLimit = maxAPIKeyLength
ti.Width = 80
ti.Focus()
return authModel{
input: ti,
serverURL: serverURL,
errMsg: initialErr,
}
}
func (m authModel) Update(msg tea.Msg) (authModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.input.Width = max(msg.Width-14, 20) // account for prompt width
return m, nil
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC, tea.KeyCtrlD:
m.aborted = true
return m, nil
default:
if m.state == authValidating {
return m, nil
}
}
switch msg.Type {
case tea.KeyEnter:
key := strings.TrimSpace(m.input.Value())
if key == "" {
m.errMsg = "No key entered."
m.retries++
if m.retries >= maxAPIKeyRetries {
m.errMsg = "Too many failed attempts. Disconnecting."
m.aborted = true
return m, nil
}
m.input.SetValue("")
return m, nil
}
m.state = authValidating
m.errMsg = ""
serverURL := m.serverURL
return m, func() tea.Msg {
return authValidatedMsg{key: key, err: validateAPIKey(serverURL, key)}
}
}
case authValidatedMsg:
if msg.err != nil {
m.state = authInput
m.errMsg = msg.err.Error()
m.retries++
if m.retries >= maxAPIKeyRetries {
m.errMsg = "Too many failed attempts. Disconnecting."
m.aborted = true
return m, nil
}
m.input.SetValue("")
return m, m.input.Focus()
}
m.apiKey = msg.key
m.state = authDone
return m, nil
}
if m.state == authInput {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
return m, nil
}
func (m authModel) View() string {
settingsURL := strings.TrimRight(m.serverURL, "/") + "/app/settings/accounts-access"
var b strings.Builder
b.WriteString("\n")
b.WriteString(" \x1b[1;35mOnyx CLI\x1b[0m\n")
b.WriteString(" \x1b[90m" + m.serverURL + "\x1b[0m\n")
b.WriteString("\n")
b.WriteString(" Generate an API key at:\n")
b.WriteString(" \x1b[4;34m" + settingsURL + "\x1b[0m\n")
b.WriteString("\n")
b.WriteString(" \x1b[90mTip: skip this prompt by passing your key via SSH:\x1b[0m\n")
b.WriteString(" \x1b[90m export ONYX_API_KEY=<key>\x1b[0m\n")
b.WriteString(" \x1b[90m ssh -o SendEnv=ONYX_API_KEY <host> -p <port>\x1b[0m\n")
b.WriteString("\n")
if m.errMsg != "" {
b.WriteString(" \x1b[1;31m" + m.errMsg + "\x1b[0m\n\n")
}
switch m.state {
case authDone:
b.WriteString(" \x1b[32mAuthenticated.\x1b[0m\n")
case authValidating:
b.WriteString(" \x1b[90mValidating…\x1b[0m\n")
default:
b.WriteString(m.input.View() + "\n")
}
return b.String()
}
// --- serve model (wraps auth → TUI in a single bubbletea program) ---
type serveModel struct {
auth authModel
tui tea.Model
authed bool
serverCfg config.OnyxCliConfig
width int
height int
}
func newServeModel(serverCfg config.OnyxCliConfig, initialErr string) serveModel {
return serveModel{
auth: newAuthModel(serverCfg.ServerURL, initialErr),
serverCfg: serverCfg,
}
}
func (m serveModel) Init() tea.Cmd {
return textinput.Blink
}
func (m serveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.authed {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
}
var cmd tea.Cmd
m.auth, cmd = m.auth.Update(msg)
if m.auth.aborted {
return m, tea.Quit
}
if m.auth.apiKey != "" {
cfg := config.OnyxCliConfig{
ServerURL: m.serverCfg.ServerURL,
APIKey: m.auth.apiKey,
DefaultAgentID: m.serverCfg.DefaultAgentID,
}
m.tui = tui.NewModel(cfg)
m.authed = true
w, h := m.width, m.height
return m, tea.Batch(
tea.EnterAltScreen,
tea.EnableMouseCellMotion,
m.tui.Init(),
func() tea.Msg { return tea.WindowSizeMsg{Width: w, Height: h} },
)
}
return m, cmd
}
var cmd tea.Cmd
m.tui, cmd = m.tui.Update(msg)
return m, cmd
}
func (m serveModel) View() string {
if !m.authed {
return m.auth.View()
}
return m.tui.View()
}
// --- serve command ---
func newServeCmd() *cobra.Command {
var (
host string
port int
keyPath string
idleTimeout time.Duration
maxSessionTimeout time.Duration
rateLimitPerMin int
rateLimitBurst int
rateLimitCache int
)
cmd := &cobra.Command{
Use: "serve",
Short: "Serve the Onyx TUI over SSH",
Long: `Start an SSH server that presents the interactive Onyx chat TUI to
connecting clients. Each SSH session gets its own independent TUI instance.
Clients are prompted for their Onyx API key on connect. The key can also be
provided via the ONYX_API_KEY environment variable to skip the prompt:
ssh -o SendEnv=ONYX_API_KEY host -p port
The server URL is taken from the server operator's config. The server
auto-generates an Ed25519 host key on first run if the key file does not
already exist. The host key path can also be set via the ONYX_SSH_HOST_KEY
environment variable (the --host-key flag takes precedence).
Example:
onyx-cli serve --port 2222
ssh localhost -p 2222`,
RunE: func(cmd *cobra.Command, args []string) error {
serverCfg := config.Load()
if serverCfg.ServerURL == "" {
return fmt.Errorf("server URL is not configured; run 'onyx-cli configure' first")
}
if !cmd.Flags().Changed("host-key") {
if v := os.Getenv(config.EnvSSHHostKey); v != "" {
keyPath = v
}
}
if rateLimitPerMin <= 0 {
return fmt.Errorf("--rate-limit-per-minute must be > 0")
}
if rateLimitBurst <= 0 {
return fmt.Errorf("--rate-limit-burst must be > 0")
}
if rateLimitCache <= 0 {
return fmt.Errorf("--rate-limit-cache must be > 0")
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
connectionLimiter := ratelimiter.NewRateLimiter(
rate.Limit(float64(rateLimitPerMin)/60.0),
rateLimitBurst,
rateLimitCache,
)
handler := func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
apiKey := strings.TrimSpace(sessionEnv(s, config.EnvAPIKey))
var envErr string
if apiKey != "" {
if err := validateAPIKey(serverCfg.ServerURL, apiKey); err != nil {
envErr = fmt.Sprintf("ONYX_API_KEY from SSH environment is invalid: %s", err.Error())
apiKey = ""
}
}
if apiKey != "" {
// Env key is valid — go straight to the TUI.
cfg := config.OnyxCliConfig{
ServerURL: serverCfg.ServerURL,
APIKey: apiKey,
DefaultAgentID: serverCfg.DefaultAgentID,
}
return tui.NewModel(cfg), []tea.ProgramOption{
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
}
}
// No valid env key — show auth prompt, then transition
// to the TUI within the same bubbletea program.
return newServeModel(serverCfg, envErr), []tea.ProgramOption{
tea.WithMouseCellMotion(),
}
}
serverOptions := []ssh.Option{
wish.WithAddress(addr),
wish.WithHostKeyPath(keyPath),
wish.WithMiddleware(
bubbletea.Middleware(handler),
activeterm.Middleware(),
ratelimiter.Middleware(connectionLimiter),
logging.Middleware(),
),
}
if idleTimeout > 0 {
serverOptions = append(serverOptions, wish.WithIdleTimeout(idleTimeout))
}
if maxSessionTimeout > 0 {
serverOptions = append(serverOptions, wish.WithMaxTimeout(maxSessionTimeout))
}
s, err := wish.NewServer(serverOptions...)
if err != nil {
return fmt.Errorf("could not create SSH server: %w", err)
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
log.Info("Starting Onyx SSH server", "addr", addr)
log.Info("Connect with", "cmd", fmt.Sprintf("ssh %s -p %d", host, port))
errCh := make(chan error, 1)
go func() {
if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("SSH server failed", "error", err)
errCh <- err
}
}()
var serverErr error
select {
case <-done:
case serverErr = <-errCh:
}
signal.Stop(done)
log.Info("Shutting down SSH server")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if shutdownErr := s.Shutdown(ctx); shutdownErr != nil {
return errors.Join(serverErr, shutdownErr)
}
return serverErr
},
}
cmd.Flags().StringVar(&host, "host", "localhost", "Host address to bind to")
cmd.Flags().IntVarP(&port, "port", "p", 2222, "Port to listen on")
cmd.Flags().StringVar(&keyPath, "host-key", filepath.Join(config.ConfigDir(), "host_ed25519"),
"Path to SSH host key (auto-generated if missing)")
cmd.Flags().DurationVar(
&idleTimeout,
"idle-timeout",
defaultServeIdleTimeout,
"Disconnect idle clients after this duration (set 0 to disable)",
)
cmd.Flags().DurationVar(
&maxSessionTimeout,
"max-session-timeout",
defaultServeMaxSessionTimeout,
"Maximum lifetime of a client session (set 0 to disable)",
)
cmd.Flags().IntVar(
&rateLimitPerMin,
"rate-limit-per-minute",
defaultServeRateLimitPerMinute,
"Per-IP connection rate limit (new sessions per minute)",
)
cmd.Flags().IntVar(
&rateLimitBurst,
"rate-limit-burst",
defaultServeRateLimitBurst,
"Per-IP burst limit for connection attempts",
)
cmd.Flags().IntVar(
&rateLimitCache,
"rate-limit-cache",
defaultServeRateLimitCacheSize,
"Maximum number of IP limiter entries tracked in memory",
)
return cmd
}

View File

@@ -1,10 +1,14 @@
package cmd
import (
"context"
"fmt"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/version"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
@@ -35,6 +39,25 @@ func newValidateConfigCmd() *cobra.Command {
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Status: connected and authenticated")
// Check backend version compatibility
vCtx, vCancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer vCancel()
backendVersion, err := client.GetBackendVersion(vCtx)
if err != nil {
log.WithError(err).Debug("could not fetch backend version")
} else if backendVersion == "" {
log.Debug("server returned empty version string")
} else {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Version: %s\n", backendVersion)
min := version.MinServer()
if sv, ok := version.Parse(backendVersion); ok && sv.LessThan(min) {
log.Warnf("Server version %s is below minimum required %d.%d, please upgrade",
backendVersion, min.Major, min.Minor)
}
}
return nil
},
}

View File

@@ -1,45 +1,63 @@
module github.com/onyx-dot-app/onyx/cli
go 1.26.0
go 1.26.1
require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/glamour v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.9.1
golang.org/x/term v0.30.0
golang.org/x/text v0.34.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/time v0.15.0
)
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/keygen v0.5.4 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/conpty v0.2.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca // indirect
github.com/charmbracelet/x/input v0.3.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.4 // indirect
github.com/yuin/goldmark-emoji v1.0.3 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.31.0 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
)

View File

@@ -1,55 +1,89 @@
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca h1:QQoyQLgUzojMNWHVHToN6d9qTvT0KWtxUKIRPx/Ox5o=
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/input v0.3.7 h1:UzVbkt1vgM9dBQ+K+uRolBlN6IF2oLchmPKKo/aucXo=
github.com/charmbracelet/x/input v0.3.7/go.mod h1:ZSS9Cia6Cycf2T6ToKIOxeTBTDwl25AGwArJuGaOBH8=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -60,35 +94,47 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -34,8 +34,7 @@ class CustomBuildHook(BuildHookInterface):
# Build the Go binary (always rebuild to ensure correct version injection)
if not os.path.exists(binary_name):
print(f"Building Go binary '{binary_name}'...")
pkg = "github.com/onyx-dot-app/onyx/cli/cmd"
ldflags = f"-X {pkg}.version={tag}" f" -X {pkg}.commit={commit}" " -s -w"
ldflags = f"-X main.version={tag} -X main.commit={commit} -s -w"
subprocess.check_call( # noqa: S603
["go", "build", f"-ldflags={ldflags}", "-o", binary_name],
)

View File

@@ -270,6 +270,17 @@ func (c *Client) UploadFile(ctx context.Context, filePath string) (*models.FileD
}, nil
}
// GetBackendVersion fetches the backend version string from /api/version.
func (c *Client) GetBackendVersion(ctx context.Context) (string, error) {
var resp struct {
BackendVersion string `json:"backend_version"`
}
if err := c.doJSON(ctx, "GET", "/api/version", nil, &resp); err != nil {
return "", err
}
return resp.BackendVersion, nil
}
// StopChatSession sends a stop signal for a streaming session (best-effort).
func (c *Client) StopChatSession(ctx context.Context, sessionID string) {
req, err := c.newRequest(ctx, "POST", "/api/chat/stop-chat-session/"+sessionID, nil)

View File

@@ -9,9 +9,10 @@ import (
)
const (
EnvServerURL = "ONYX_SERVER_URL"
EnvAPIKey = "ONYX_API_KEY"
EnvServerURL = "ONYX_SERVER_URL"
EnvAPIKey = "ONYX_API_KEY"
EnvAgentID = "ONYX_PERSONA_ID"
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
)
// OnyxCliConfig holds the CLI configuration.
@@ -35,8 +36,8 @@ func (c OnyxCliConfig) IsConfigured() bool {
return c.APIKey != ""
}
// configDir returns ~/.config/onyx-cli
func configDir() string {
// ConfigDir returns ~/.config/onyx-cli
func ConfigDir() string {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "onyx-cli")
}
@@ -49,7 +50,7 @@ func configDir() string {
// ConfigFilePath returns the full path to the config file.
func ConfigFilePath() string {
return filepath.Join(configDir(), "config.json")
return filepath.Join(ConfigDir(), "config.json")
}
// ConfigExists checks if the config file exists on disk.
@@ -87,7 +88,7 @@ func Load() OnyxCliConfig {
// Save writes the config to disk, creating parent directories if needed.
func Save(cfg OnyxCliConfig) error {
dir := configDir()
dir := ConfigDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}

View File

@@ -0,0 +1,58 @@
// Package version provides semver parsing and compatibility checks.
package version
import (
"strconv"
"strings"
)
// Semver holds parsed semantic version components.
type Semver struct {
Major int
Minor int
Patch int
}
// minServer is the minimum backend version required by this CLI.
var minServer = Semver{Major: 3, Minor: 0, Patch: 0}
// MinServer returns the minimum backend version required by this CLI.
func MinServer() Semver { return minServer }
// Parse extracts major, minor, patch from a version string like "3.1.2" or "v3.1.2".
// Returns ok=false if the string is not valid semver.
func Parse(v string) (Semver, bool) {
v = strings.TrimPrefix(v, "v")
// Strip any pre-release suffix (e.g. "-beta.1") and build metadata (e.g. "+build.1")
if idx := strings.IndexAny(v, "-+"); idx != -1 {
v = v[:idx]
}
parts := strings.SplitN(v, ".", 3)
if len(parts) != 3 {
return Semver{}, false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return Semver{}, false
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return Semver{}, false
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return Semver{}, false
}
return Semver{Major: major, Minor: minor, Patch: patch}, true
}
// LessThan reports whether s is strictly less than other.
func (s Semver) LessThan(other Semver) bool {
if s.Major != other.Major {
return s.Major < other.Major
}
if s.Minor != other.Minor {
return s.Minor < other.Minor
}
return s.Patch < other.Patch
}

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling", "go-bin~=1.24.11", "manygo"]
requires = ["hatchling==1.29.0", "go-bin~=1.26.1", "manygo==0.2.0"]
build-backend = "hatchling.build"
[project]

View File

@@ -66,7 +66,7 @@ backend = [
"jsonref==1.1.0",
"kubernetes==31.0.0",
"trafilatura==1.12.2",
"langchain-core==1.2.11",
"langchain-core==1.2.22",
"lazy_imports==1.0.1",
"lxml==5.3.0",
"Mako==1.2.4",

View File

@@ -1,6 +1,6 @@
module github.com/onyx-dot-app/onyx/tools/ods
go 1.26.0
go 1.26.1
require (
github.com/gdamore/tcell/v2 v2.13.8

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling", "go-bin~=1.26.0", "manygo"]
requires = ["hatchling==1.29.0", "go-bin~=1.26.1", "manygo==0.2.0"]
build-backend = "hatchling.build"
[project]

108
uv.lock generated
View File

@@ -1255,61 +1255,61 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.5"
version = "46.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
{ url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
{ url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
{ url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
{ url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" },
{ url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" },
{ url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
]
[[package]]
@@ -3048,7 +3048,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.11"
version = "1.2.22"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -3060,9 +3060,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/17/1943cedfc118e04b8128e4c3e1dbf0fa0ea58eefddbb6198cfd699d19f01/langchain_core-1.2.11.tar.gz", hash = "sha256:f164bb36602dd74a3a50c1334fca75309ad5ed95767acdfdbb9fa95ce28a1e01", size = 831211, upload-time = "2026-02-10T20:35:28.35Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/30/1f80e3fc674353cad975ed5294353d42512535d2094ef032c06454c2c873/langchain_core-1.2.11-py3-none-any.whl", hash = "sha256:ae11ceb8dda60d0b9d09e763116e592f1683327c17be5b715f350fd29aee65d3", size = 500062, upload-time = "2026-02-10T20:35:26.698Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
]
[[package]]
@@ -4439,7 +4439,7 @@ requires-dist = [
{ name = "jsonref", marker = "extra == 'backend'", specifier = "==1.1.0" },
{ name = "kubernetes", specifier = ">=31.0.0" },
{ name = "kubernetes", marker = "extra == 'backend'", specifier = "==31.0.0" },
{ name = "langchain-core", marker = "extra == 'backend'", specifier = "==1.2.11" },
{ name = "langchain-core", marker = "extra == 'backend'", specifier = "==1.2.22" },
{ name = "langfuse", marker = "extra == 'backend'", specifier = "==3.10.0" },
{ name = "lazy-imports", marker = "extra == 'backend'", specifier = "==1.0.1" },
{ name = "litellm", specifier = "==1.81.6" },

View File

@@ -104,7 +104,7 @@ function Button({
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<div className="flex flex-row items-center gap-1 interactive-foreground">
<div className="flex flex-row items-center gap-1">
{iconWrapper(Icon, size, !!children)}
{labelEl}

View File

@@ -67,7 +67,7 @@ function FilterButton({
state={active ? "selected" : "empty"}
>
<Interactive.Container type="button">
<div className="interactive-foreground flex flex-row items-center gap-1">
<div className="flex flex-row items-center gap-1">
{iconWrapper(Icon, "lg", true)}
<Text font="main-ui-action" color="inherit" nowrap>
{children}

View File

@@ -16,7 +16,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
type ContentPassthroughProps = DistributiveOmit<
ContentActionProps,
"paddingVariant" | "widthVariant" | "ref" | "withInteractive"
"paddingVariant" | "widthVariant" | "ref"
>;
type LineItemButtonOwnProps = Pick<
@@ -92,7 +92,6 @@ function LineItemButton({
>
<ContentAction
{...(contentActionProps as ContentActionProps)}
withInteractive
paddingVariant="fit"
/>
</Interactive.Container>

View File

@@ -132,7 +132,7 @@ function OpenButton({
>
<div
className={cn(
"interactive-foreground flex flex-row items-center",
"flex flex-row items-center",
justifyContent === "between" ? "w-full justify-between" : "gap-1",
foldable &&
justifyContent !== "between" &&

View File

@@ -1,3 +1,5 @@
"use client";
import "@opal/components/buttons/select-button/styles.css";
import {
Interactive,
@@ -84,11 +86,13 @@ function SelectButton({
const isLarge = size === "lg";
const labelEl = children ? (
<span className="opal-select-button-label">
<Text font={isLarge ? "main-ui-body" : "secondary-body"} color="inherit">
{children}
</Text>
</span>
<Text
font={isLarge ? "main-ui-body" : "secondary-body"}
color="inherit"
nowrap
>
{children}
</Text>
) : null;
const button = (
@@ -103,7 +107,7 @@ function SelectButton({
>
<div
className={cn(
"opal-select-button interactive-foreground",
"opal-select-button",
foldable && "interactive-foldable-host"
)}
>

View File

@@ -3,7 +3,3 @@
.opal-select-button {
@apply flex flex-row items-center gap-1;
}
.opal-select-button-label {
@apply whitespace-nowrap;
}

View File

@@ -390,14 +390,12 @@ function PaginationCount({
<span className={textClasses(size, "muted")}>of</span>
{totalItems}
{units && (
<span className="ml-1">
<Text
color="inherit"
font={size === "sm" ? "secondary-body" : "main-ui-muted"}
>
{units}
</Text>
</span>
<Text
color="inherit"
font={size === "sm" ? "secondary-body" : "main-ui-muted"}
>
{units}
</Text>
)}
</span>

View File

@@ -1,5 +1,4 @@
import "@opal/components/tag/styles.css";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import { Text } from "@opal/components";
import { cn } from "@opal/utils";
@@ -46,20 +45,22 @@ function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
const config = COLOR_CONFIG[color];
return (
<div className={cn("opal-auxiliary-tag", config.bg)} data-size={size}>
<div
className={cn("opal-auxiliary-tag", config.bg, config.text)}
data-size={size}
>
{Icon && (
<div className="opal-auxiliary-tag-icon-container">
<Icon className={cn("opal-auxiliary-tag-icon", config.text)} />
</div>
)}
<span className={cn("opal-auxiliary-tag-title px-[2px]", config.text)}>
<Text
font={size === "md" ? "secondary-body" : "figure-small-value"}
color="inherit"
>
{title}
</Text>
</span>
<Text
font={size === "md" ? "secondary-body" : "figure-small-value"}
color="inherit"
nowrap
>
{title}
</Text>
</div>
);
}

View File

@@ -13,7 +13,9 @@ const SAFE_PROTOCOL = /^https?:|^mailto:|^tel:/i;
const ALLOWED_ELEMENTS = ["p", "br", "a", "strong", "em", "code", "del"];
const INLINE_COMPONENTS = {
p: ({ children }: { children?: ReactNode }) => <>{children}</>,
p: ({ children }: { children?: ReactNode }) => (
<span className="block">{children}</span>
),
a: ({ children, href }: { children?: ReactNode; href?: string }) => {
if (!href || !SAFE_PROTOCOL.test(href)) {
return <>{children}</>;

View File

@@ -125,6 +125,7 @@ function Text({
...rest
}: TextProps) {
const resolvedClassName = cn(
"px-[2px]",
FONT_CONFIG[font],
COLOR_CONFIG[color],
nowrap && "whitespace-nowrap",

View File

@@ -18,9 +18,11 @@ export {
import { InteractiveStateless } from "@opal/core/interactive/stateless/components";
import { InteractiveStateful } from "@opal/core/interactive/stateful/components";
import { InteractiveContainer } from "@opal/core/interactive/container/components";
import { InteractiveSimple } from "@opal/core/interactive/simple/components";
import { Foldable } from "@opal/core/interactive/foldable/components";
const Interactive = {
Simple: InteractiveSimple,
Stateless: InteractiveStateless,
Stateful: InteractiveStateful,
Container: InteractiveContainer,
@@ -50,3 +52,5 @@ export type {
} from "@opal/core/interactive/container/components";
export type { FoldableProps } from "@opal/core/interactive/foldable/components";
export type { InteractiveSimpleProps } from "@opal/core/interactive/simple/components";

View File

@@ -9,7 +9,6 @@ const VARIANT_PROMINENCE_MAP: Record<string, string[]> = {
default: ["primary", "secondary", "tertiary", "internal"],
action: ["primary", "secondary", "tertiary", "internal"],
danger: ["primary", "secondary", "tertiary", "internal"],
none: [],
};
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
@@ -43,7 +42,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Secondary</span>
<span>Secondary</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -53,7 +52,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Primary</span>
<span>Primary</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -63,7 +62,7 @@ export const Default: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Tertiary</span>
<span>Tertiary</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -115,9 +114,7 @@ export const VariantMatrix: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">
{prominence}
</span>
<span>{prominence}</span>
</Interactive.Container>
</Interactive.Stateless>
<span
@@ -150,7 +147,7 @@ export const Sizes: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border heightVariant={size}>
<span className="interactive-foreground">{size}</span>
<span>{size}</span>
</Interactive.Container>
</Interactive.Stateless>
))}
@@ -168,7 +165,7 @@ export const WidthFull: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border widthVariant="full">
<span className="interactive-foreground">Full width container</span>
<span>Full width container</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -187,7 +184,7 @@ export const Rounding: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border roundingVariant={rounding}>
<span className="interactive-foreground">{rounding}</span>
<span>{rounding}</span>
</Interactive.Container>
</Interactive.Stateless>
))}
@@ -207,7 +204,7 @@ export const DisabledStory: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Disabled</span>
<span>Disabled</span>
</Interactive.Container>
</Interactive.Stateless>
</Disabled>
@@ -218,7 +215,7 @@ export const DisabledStory: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Enabled</span>
<span>Enabled</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -236,7 +233,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Forced hover</span>
<span>Forced hover</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -247,7 +244,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Forced active</span>
<span>Forced active</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -257,7 +254,7 @@ export const Interaction: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Normal (rest)</span>
<span>Normal (rest)</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -274,7 +271,7 @@ export const WithBorder: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">With border</span>
<span>With border</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -284,7 +281,7 @@ export const WithBorder: StoryObj = {
onClick={() => {}}
>
<Interactive.Container>
<span className="interactive-foreground">Without border</span>
<span>Without border</span>
</Interactive.Container>
</Interactive.Stateless>
</div>
@@ -296,7 +293,7 @@ export const AsLink: StoryObj = {
render: () => (
<Interactive.Stateless variant="action" href="/settings">
<Interactive.Container border>
<span className="interactive-foreground">Go to Settings</span>
<span>Go to Settings</span>
</Interactive.Container>
</Interactive.Stateless>
),
@@ -312,7 +309,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Selected (light)</span>
<span>Selected (light)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -322,7 +319,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Unselected (light)</span>
<span>Unselected (light)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -332,7 +329,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Selected (heavy)</span>
<span>Selected (heavy)</span>
</Interactive.Container>
</Interactive.Stateful>
@@ -342,7 +339,7 @@ export const SelectVariant: StoryObj = {
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Unselected (heavy)</span>
<span>Unselected (heavy)</span>
</Interactive.Container>
</Interactive.Stateful>
</div>

View File

@@ -21,9 +21,10 @@
--interactive-easing: ease-in-out;
}
/* Base interactive surface */
/* Base interactive surface — sets color directly so all descendants inherit. */
.interactive {
@apply cursor-pointer select-none;
color: var(--interactive-foreground);
transition:
background-color var(--interactive-duration) var(--interactive-easing),
--interactive-foreground var(--interactive-duration)
@@ -43,14 +44,8 @@
@apply border;
}
/* Utility classes — descendants opt in to parent-controlled foreground color.
No transition here — the parent interpolates the variables directly. */
.interactive-foreground {
color: var(--interactive-foreground);
}
/* Icon foreground — reads from --interactive-foreground-icon, which defaults
to --interactive-foreground via the variant CSS rules. */
/* Icon foreground — reads from --interactive-foreground-icon, which may differ
from --interactive-foreground (e.g. muted icons beside normal text). */
.interactive-foreground-icon {
color: var(--interactive-foreground-icon);
}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import { useDisabled } from "@opal/core/disabled/components";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InteractiveSimpleProps
extends Omit<
React.HTMLAttributes<HTMLElement>,
"className" | "style" | "color"
> {
ref?: React.Ref<HTMLElement>;
/**
* Tailwind group class (e.g. `"group/Card"`) for `group-hover:*` utilities.
*/
group?: string;
/**
* URL to navigate to when clicked. Passed through Slot to the child.
*/
href?: string;
/**
* Link target (e.g. `"_blank"`). Only used when `href` is provided.
*/
target?: string;
}
// ---------------------------------------------------------------------------
// InteractiveSimple
// ---------------------------------------------------------------------------
/**
* Minimal interactive surface primitive.
*
* Provides cursor styling, click handling, disabled integration, and
* optional link/group support — but **no color or background styling**.
*
* Use this for elements that need interactivity (click, cursor, disabled)
* without participating in the Interactive color system.
*
* Uses Radix `Slot` — merges props onto a single child element without
* adding any DOM node.
*
* @example
* ```tsx
* <Interactive.Simple onClick={handleClick} group="group/Card">
* <Card>...</Card>
* </Interactive.Simple>
* ```
*/
function InteractiveSimple({
ref,
group,
href,
target,
...props
}: InteractiveSimpleProps) {
const { isDisabled, allowClick } = useDisabled();
const classes = cn(
"cursor-pointer select-none",
isDisabled && "cursor-not-allowed",
!props.onClick && !href && "!cursor-default !select-auto",
group
);
const { onClick, ...slotProps } = props;
const linkAttrs = href
? {
href: isDisabled ? undefined : href,
target,
rel: target === "_blank" ? "noopener noreferrer" : undefined,
}
: {};
return (
<Slot
ref={ref}
className={classes}
aria-disabled={isDisabled || undefined}
{...linkAttrs}
{...slotProps}
onClick={
isDisabled && !allowClick
? href
? (e: React.MouseEvent) => e.preventDefault()
: undefined
: onClick
}
/>
);
}
export { InteractiveSimple, type InteractiveSimpleProps };

View File

@@ -15,7 +15,8 @@ type InteractiveStatefulVariant =
| "select-heavy"
| "select-tinted"
| "select-filter"
| "sidebar";
| "sidebar-heavy"
| "sidebar-light";
type InteractiveStatefulState = "empty" | "filled" | "selected";
type InteractiveStatefulInteraction = "rest" | "hover" | "active";
@@ -33,7 +34,8 @@ interface InteractiveStatefulProps
* - `"select-heavy"` — tinted selected background (for list rows, model pickers)
* - `"select-tinted"` — like select-heavy but with a tinted rest background
* - `"select-filter"` — like select-tinted for empty/filled; selected state uses inverted tint backgrounds and inverted text (for filter buttons)
* - `"sidebar"` — for sidebar navigation items
* - `"sidebar-heavy"` — sidebar navigation items: muted when unselected (text-03/text-02), bold when selected (text-04/text-03)
* - `"sidebar-light"` — sidebar navigation items: uniformly muted across all states (text-02/text-02)
*
* @default "select-heavy"
*/

View File

@@ -392,49 +392,115 @@
}
/* ===========================================================================
Sidebar
Sidebar-Heavy
Not selected: muted (text-03 / icon text-02)
Selected: default (text-04 / icon text-03)
=========================================================================== */
/* ---------------------------------------------------------------------------
Sidebar — Empty
Sidebar-Heavy — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"] {
@apply bg-transparent;
--interactive-foreground: var(--text-03);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="empty"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar — Filled
Sidebar-Heavy — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"] {
@apply bg-transparent;
--interactive-foreground: var(--text-03);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="filled"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar — Selected
Sidebar-Heavy — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"] {
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"] {
@apply bg-background-tint-00;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"]:hover:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar"][data-interactive-state="selected"][data-interaction="hover"]:not(
.interactive[data-interactive-variant="sidebar-heavy"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ===========================================================================
Sidebar-Light
All states: prominence="muted-2x" colors (text-02 / icon text-02)
=========================================================================== */
/* ---------------------------------------------------------------------------
Sidebar-Light — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"] {
@apply bg-transparent;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar-Light — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"] {
@apply bg-transparent;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;
}
/* ---------------------------------------------------------------------------
Sidebar-Light — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"] {
@apply bg-background-tint-00;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="sidebar-light"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-03;

View File

@@ -10,7 +10,7 @@ import type { ButtonType, WithoutStyles } from "@opal/types";
// Types
// ---------------------------------------------------------------------------
type InteractiveStatelessVariant = "none" | "default" | "action" | "danger";
type InteractiveStatelessVariant = "default" | "action" | "danger";
type InteractiveStatelessProminence =
| "primary"
| "secondary"
@@ -108,8 +108,8 @@ function InteractiveStateless({
);
const dataAttrs = {
"data-interactive-variant": variant !== "none" ? variant : undefined,
"data-interactive-prominence": variant !== "none" ? prominence : undefined,
"data-interactive-variant": variant,
"data-interactive-prominence": prominence,
"data-interaction": interaction !== "rest" ? interaction : undefined,
"data-disabled": isDisabled ? "true" : undefined,
"aria-disabled": isDisabled || undefined,

View File

@@ -4,10 +4,8 @@ import { Button } from "@opal/components/buttons/button/components";
import type { ContainerSizeVariants } from "@opal/types";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useState } from "react";
@@ -24,8 +22,8 @@ interface ContentLgPresetConfig {
iconContainerPadding: string;
/** Gap between icon container and content (CSS value). */
gap: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Opal font name for the title (without `font-` prefix). */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
@@ -53,9 +51,6 @@ interface ContentLgProps {
/** Size preset. Default: `"headline"`. */
sizePreset?: ContentLgSizePreset;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -69,7 +64,7 @@ const CONTENT_LG_PRESETS: Record<ContentLgSizePreset, ContentLgPresetConfig> = {
iconSize: "2rem",
iconContainerPadding: "p-0.5",
gap: "0.25rem",
titleFont: "font-heading-h2",
titleFont: "heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
@@ -78,7 +73,7 @@ const CONTENT_LG_PRESETS: Record<ContentLgSizePreset, ContentLgPresetConfig> = {
iconSize: "1.25rem",
iconContainerPadding: "p-1",
gap: "0rem",
titleFont: "font-heading-h3-muted",
titleFont: "heading-h3-muted",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
@@ -96,7 +91,6 @@ function ContentLg({
description,
editable,
onTitleChange,
withInteractive,
ref,
}: ContentLgProps) {
const [editing, setEditing] = useState(false);
@@ -116,12 +110,7 @@ function ContentLg({
}
return (
<div
ref={ref}
className="opal-content-lg"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
<div ref={ref} className="opal-content-lg" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(
@@ -142,14 +131,17 @@ function ContentLg({
{editing ? (
<div className="opal-content-lg-input-sizer">
<span
className={cn("opal-content-lg-input-mirror", config.titleFont)}
className={cn(
"opal-content-lg-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
<input
className={cn(
"opal-content-lg-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -169,19 +161,15 @@ function ContentLg({
/>
</div>
) : (
<span
className={cn(
"opal-content-lg-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{editable && !editing && (
@@ -204,8 +192,10 @@ function ContentLg({
</div>
{description && toPlainString(description) && (
<div className="opal-content-lg-description font-secondary-body text-text-03">
{resolveStr(description)}
<div className="opal-content-lg-description">
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>

View File

@@ -8,10 +8,8 @@ import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
import SvgXOctagon from "@opal/icons/x-octagon";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
@@ -23,16 +21,18 @@ type ContentMdSizePreset = "main-content" | "main-ui" | "secondary";
type ContentMdAuxIcon = "info-gray" | "info-blue" | "warning" | "error";
type ContentMdSuffix = "optional" | (string & {});
interface ContentMdPresetConfig {
iconSize: string;
iconContainerPadding: string;
iconColorClass: string;
titleFont: string;
titleFont: TextFont;
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: ContainerSizeVariants;
editButtonPadding: string;
optionalFont: string;
optionalFont: TextFont;
/** Aux icon size = lineHeight 2 × p-0.5. */
auxIconSize: string;
/** Left indent for the description so it aligns with the title (past the icon). */
@@ -55,11 +55,11 @@ interface ContentMdProps {
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** When `true`, renders "(Optional)" beside the title. */
optional?: boolean;
/** Custom muted suffix rendered beside the title. */
titleSuffix?: string;
/**
* Muted suffix rendered beside the title.
* Use `"optional"` for the standard "(Optional)" label, or pass any string.
*/
suffix?: ContentMdSuffix;
/** Auxiliary status icon rendered beside the title. */
auxIcon?: ContentMdAuxIcon;
@@ -70,18 +70,6 @@ interface ContentMdProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: ContentMdSizePreset;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Optional class name applied to the title element. */
titleClassName?: string;
/** Optional class name applied to the icon element. */
iconClassName?: string;
/** Content rendered below the description, indented to align with it. */
bottomChildren?: React.ReactNode;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -95,11 +83,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "1rem",
iconContainerPadding: "p-1",
iconColorClass: "text-text-04",
titleFont: "font-main-content-emphasis",
titleFont: "main-content-emphasis",
lineHeight: "1.5rem",
editButtonSize: "sm",
editButtonPadding: "p-0",
optionalFont: "font-main-content-muted",
optionalFont: "main-content-muted",
auxIconSize: "1.25rem",
descriptionIndent: "1.625rem",
},
@@ -107,11 +95,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-03",
titleFont: "font-main-ui-action",
titleFont: "main-ui-action",
lineHeight: "1.25rem",
editButtonSize: "xs",
editButtonPadding: "p-0",
optionalFont: "font-main-ui-muted",
optionalFont: "main-ui-muted",
auxIconSize: "1rem",
descriptionIndent: "1.375rem",
},
@@ -119,11 +107,11 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-04",
titleFont: "font-secondary-action",
titleFont: "secondary-action",
lineHeight: "1rem",
editButtonSize: "2xs",
editButtonPadding: "p-0",
optionalFont: "font-secondary-action",
optionalFont: "secondary-action",
auxIconSize: "0.75rem",
descriptionIndent: "1.125rem",
},
@@ -149,15 +137,10 @@ function ContentMd({
description,
editable,
onTitleChange,
optional,
titleSuffix,
suffix,
auxIcon,
tag,
sizePreset = "main-ui",
withInteractive,
titleClassName,
iconClassName,
bottomChildren,
ref,
}: ContentMdProps) {
const [editing, setEditing] = useState(false);
@@ -178,11 +161,7 @@ function ContentMd({
}
return (
<div
ref={ref}
className="opal-content-md"
data-interactive={withInteractive || undefined}
>
<div ref={ref} className="opal-content-md">
<div
className="opal-content-md-header"
data-editing={editing || undefined}
@@ -196,11 +175,7 @@ function ContentMd({
style={{ minHeight: config.lineHeight }}
>
<Icon
className={cn(
"opal-content-md-icon",
config.iconColorClass,
iconClassName
)}
className={cn("opal-content-md-icon", config.iconColorClass)}
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
@@ -210,7 +185,10 @@ function ContentMd({
{editing ? (
<div className="opal-content-md-input-sizer">
<span
className={cn("opal-content-md-input-mirror", config.titleFont)}
className={cn(
"opal-content-md-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
@@ -218,7 +196,7 @@ function ContentMd({
ref={inputRef}
className={cn(
"opal-content-md-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -238,29 +216,21 @@ function ContentMd({
/>
</div>
) : (
<span
className={cn(
"opal-content-md-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer",
titleClassName
)}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{(optional || titleSuffix) && (
<span
className={cn(config.optionalFont, "text-text-03 shrink-0")}
style={{ height: config.lineHeight }}
>
{titleSuffix ?? "(Optional)"}
</span>
{suffix && (
<Text font={config.optionalFont} color="text-03">
{suffix === "optional" ? "(Optional)" : suffix}
</Text>
)}
{auxIcon &&
@@ -306,17 +276,12 @@ function ContentMd({
{description && toPlainString(description) && (
<div
className="opal-content-md-description font-secondary-body text-text-03"
className="opal-content-md-description"
style={Icon ? { paddingLeft: config.descriptionIndent } : undefined}
>
{resolveStr(description)}
</div>
)}
{bottomChildren && (
<div
style={Icon ? { paddingLeft: config.descriptionIndent } : undefined}
>
{bottomChildren}
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>
@@ -327,5 +292,6 @@ export {
ContentMd,
type ContentMdProps,
type ContentMdSizePreset,
type ContentMdSuffix,
type ContentMdAuxIcon,
};

View File

@@ -1,10 +1,8 @@
"use client";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -13,15 +11,15 @@ import { cn } from "@opal/utils";
type ContentSmSizePreset = "main-content" | "main-ui" | "secondary";
type ContentSmOrientation = "vertical" | "inline" | "reverse";
type ContentSmProminence = "default" | "muted" | "muted-2x";
type ContentSmProminence = "default" | "muted";
interface ContentSmPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Font preset for the title. */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Gap between icon container and title (CSS value). */
@@ -45,9 +43,6 @@ interface ContentSmProps {
/** Title prominence. Default: `"default"`. */
prominence?: ContentSmProminence;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -60,21 +55,21 @@ const CONTENT_SM_PRESETS: Record<ContentSmSizePreset, ContentSmPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
titleFont: "font-main-content-body",
titleFont: "main-content-body",
lineHeight: "1.5rem",
gap: "0.125rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
titleFont: "font-main-ui-action",
titleFont: "main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
titleFont: "font-secondary-action",
titleFont: "secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
},
@@ -90,7 +85,6 @@ function ContentSm({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
withInteractive,
ref,
}: ContentSmProps) {
const config = CONTENT_SM_PRESETS[sizePreset];
@@ -101,7 +95,6 @@ function ContentSm({
className="opal-content-sm"
data-orientation={orientation}
data-prominence={prominence}
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (
@@ -119,13 +112,14 @@ function ContentSm({
</div>
)}
<span
className={cn("opal-content-sm-title", config.titleFont)}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
>
{resolveStr(title)}
</span>
{title}
</Text>
</div>
);
}

View File

@@ -4,10 +4,8 @@ import { Button } from "@opal/components/buttons/button/components";
import type { ContainerSizeVariants } from "@opal/types";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import {
resolveStr,
toPlainString,
} from "@opal/components/text/InlineMarkdown";
import { Text, type TextFont } from "@opal/components/text/components";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
import { cn } from "@opal/utils";
import { useState } from "react";
@@ -30,8 +28,8 @@ interface ContentXlPresetConfig {
moreIcon2Size: string;
/** Tailwind padding class for the more-icon-2 container. */
moreIcon2ContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Opal font name for the title (without `font-` prefix). */
titleFont: TextFont;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
@@ -65,9 +63,6 @@ interface ContentXlProps {
/** Optional tertiary icon rendered in the icon row. */
moreIcon2?: IconFunctionComponent;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -84,7 +79,7 @@ const CONTENT_XL_PRESETS: Record<ContentXlSizePreset, ContentXlPresetConfig> = {
moreIcon1ContainerPadding: "p-0.5",
moreIcon2Size: "2rem",
moreIcon2ContainerPadding: "p-0.5",
titleFont: "font-heading-h2",
titleFont: "heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
@@ -96,7 +91,7 @@ const CONTENT_XL_PRESETS: Record<ContentXlSizePreset, ContentXlPresetConfig> = {
moreIcon1ContainerPadding: "p-0.5",
moreIcon2Size: "1.5rem",
moreIcon2ContainerPadding: "p-0.5",
titleFont: "font-heading-h3",
titleFont: "heading-h3",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
@@ -116,7 +111,6 @@ function ContentXl({
onTitleChange,
moreIcon1: MoreIcon1,
moreIcon2: MoreIcon2,
withInteractive,
ref,
}: ContentXlProps) {
const [editing, setEditing] = useState(false);
@@ -136,11 +130,7 @@ function ContentXl({
}
return (
<div
ref={ref}
className="opal-content-xl"
data-interactive={withInteractive || undefined}
>
<div ref={ref} className="opal-content-xl">
{(Icon || MoreIcon1 || MoreIcon2) && (
<div className="opal-content-xl-icon-row">
{Icon && (
@@ -199,14 +189,17 @@ function ContentXl({
{editing ? (
<div className="opal-content-xl-input-sizer">
<span
className={cn("opal-content-xl-input-mirror", config.titleFont)}
className={cn(
"opal-content-xl-input-mirror",
`font-${config.titleFont}`
)}
>
{editValue || "\u00A0"}
</span>
<input
className={cn(
"opal-content-xl-input",
config.titleFont,
`font-${config.titleFont}`,
"text-text-04"
)}
value={editValue}
@@ -226,19 +219,15 @@ function ContentXl({
/>
</div>
) : (
<span
className={cn(
"opal-content-xl-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
<Text
font={config.titleFont}
color="inherit"
maxLines={1}
title={toPlainString(title)}
onClick={editable ? startEditing : undefined}
>
{resolveStr(title)}
</span>
{title}
</Text>
)}
{editable && !editing && (
@@ -261,8 +250,10 @@ function ContentXl({
</div>
{description && toPlainString(description) && (
<div className="opal-content-xl-description font-secondary-body text-text-03">
{resolveStr(description)}
<div className="opal-content-xl-description">
<Text font="secondary-body" color="text-03" as="p">
{description}
</Text>
</div>
)}
</div>

View File

@@ -1,39 +1,3 @@
// ---------------------------------------------------------------------------
// NOTE (@raunakab): Why Content uses resolveStr() instead of <Text>
//
// Content sub-components (ContentXl, ContentLg, ContentMd, ContentSm) render
// titles and descriptions inside styled <span> elements that carry CSS classes
// (e.g., `.opal-content-md-title`) for:
//
// 1. Truncation — `-webkit-box` + `-webkit-line-clamp` for single-line
// clamping with ellipsis. This requires the text to be a DIRECT child
// of the `-webkit-box` element. Wrapping it in a child <span> (which
// is what <Text> renders) breaks the clamping behavior.
//
// 2. Pixel-exact sizing — the wrapper <span> has an explicit `height`
// matching the font's `line-height`. Adding a child <Text> <span>
// inside creates a double-span where the inner element's line-height
// conflicts with the outer element's height, causing a ~4px vertical
// offset.
//
// 3. Interactive color overrides — CSS selectors like
// `.opal-content-md[data-interactive] .opal-content-md-title` set
// `color: var(--interactive-foreground)`. <Text> with `color="inherit"`
// can inherit this, but <Text> with any explicit color prop overrides
// it. And the wrapper <span> needs the CSS class for the selector to
// match — removing it breaks the cascade.
//
// 4. Horizontal padding — the title CSS class applies `padding: 0 0.125rem`
// (2px). Since <Text> uses WithoutStyles (no className/style), this
// padding cannot be applied to <Text> directly. A wrapper <div> was
// attempted but introduced additional layout conflicts.
//
// For these reasons, Content uses `resolveStr()` from InlineMarkdown.tsx to
// handle `string | RichStr` rendering. `resolveStr()` returns a ReactNode
// that slots directly into the existing single <span> — no extra wrapper,
// no layout conflicts, pixel-exact match with main.
// ---------------------------------------------------------------------------
import "@opal/layouts/content/styles.css";
import {
ContentSm,
@@ -98,9 +62,6 @@ interface ContentBaseProps {
*/
widthVariant?: ExtremaSizeVariants;
/** When `true`, the title color hooks into `Interactive.Stateful`/`Interactive.Stateless`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>` of the resolved layout. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -130,20 +91,12 @@ type LgContentProps = ContentBaseProps & {
type MdContentProps = ContentBaseProps & {
sizePreset: "main-content" | "main-ui" | "secondary";
variant?: "section";
/** When `true`, renders "(Optional)" beside the title in the muted font variant. */
optional?: boolean;
/** Custom muted suffix rendered beside the title. */
titleSuffix?: string;
/** Muted suffix rendered beside the title. Use `"optional"` for "(Optional)". */
suffix?: "optional" | (string & {});
/** Auxiliary status icon rendered beside the title. */
auxIcon?: "info-gray" | "info-blue" | "warning" | "error";
/** Tag rendered beside the title. */
tag?: TagProps;
/** Optional class name applied to the title element. */
titleClassName?: string;
/** Optional class name applied to the icon element. */
iconClassName?: string;
/** Content rendered below the description, indented to align with it. */
bottomChildren?: React.ReactNode;
};
/** ContentSm does not support descriptions or inline editing. */
@@ -174,7 +127,6 @@ function Content(props: ContentProps) {
sizePreset = "headline",
variant = "heading",
widthVariant = "full",
withInteractive,
ref,
...rest
} = props;
@@ -187,7 +139,6 @@ function Content(props: ContentProps) {
layout = (
<ContentXl
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentXlProps, "sizePreset">)}
/>
@@ -196,7 +147,6 @@ function Content(props: ContentProps) {
layout = (
<ContentLg
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentLgProps, "sizePreset">)}
/>
@@ -210,7 +160,6 @@ function Content(props: ContentProps) {
layout = (
<ContentMd
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentMdProps, "sizePreset">)}
/>
@@ -222,7 +171,6 @@ function Content(props: ContentProps) {
layout = (
<ContentSm
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<
React.ComponentProps<typeof ContentSm>,

View File

@@ -13,7 +13,7 @@
--------------------------------------------------------------------------- */
.opal-content-xl {
@apply flex flex-col items-start;
@apply flex flex-col items-start text-text-04;
}
/* ---------------------------------------------------------------------------
@@ -63,15 +63,6 @@
gap: 0.25rem;
}
.opal-content-xl-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-xl-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -110,7 +101,6 @@
.opal-content-xl-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -127,7 +117,7 @@
--------------------------------------------------------------------------- */
.opal-content-lg {
@apply flex flex-row items-start;
@apply flex flex-row items-start text-text-04;
}
/* ---------------------------------------------------------------------------
@@ -162,15 +152,6 @@
gap: 0.25rem;
}
.opal-content-lg-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-lg-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -209,7 +190,6 @@
.opal-content-lg-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -224,7 +204,7 @@
--------------------------------------------------------------------------- */
.opal-content-md {
@apply flex flex-col items-start;
@apply flex flex-col items-start text-text-04;
}
.opal-content-md-header {
@@ -255,15 +235,6 @@
gap: 0.25rem;
}
.opal-content-md-title {
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-md-input-sizer {
display: inline-grid;
align-items: stretch;
@@ -313,7 +284,6 @@
.opal-content-md-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
@@ -325,7 +295,7 @@
reverse : flex-row-reverse — title left, icon right
Icon color is always text-03. Title color varies by prominence
(text-04 default, text-03 muted, text-02 muted-2x) via data-prominence.
(text-04 default, text-03 muted) via data-prominence.
=========================================================================== */
/* ---------------------------------------------------------------------------
@@ -334,7 +304,7 @@
.opal-content-sm {
/* since `ContentSm` doesn't have a description, it's possible to center-align the icon and text */
@apply flex items-center;
@apply flex items-center text-text-04;
}
.opal-content-sm[data-orientation="inline"] {
@@ -367,68 +337,51 @@
@apply text-text-02;
}
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-icon {
@apply text-text-02;
}
/* ---------------------------------------------------------------------------
Title
--------------------------------------------------------------------------- */
.opal-content-sm-title {
@apply text-left overflow-hidden text-text-04 truncate;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-sm[data-prominence="muted"] .opal-content-sm-title {
.opal-content-sm[data-prominence="muted"] {
@apply text-text-03;
}
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-title {
@apply text-text-02;
}
/* ===========================================================================
Interactive-foreground opt-in
Interactive override
When a Content variant is nested inside an Interactive and
`withInteractive` is set, the title and icon delegate their color to the
`--interactive-foreground` / `--interactive-foreground-icon` CSS variables
controlled by the ancestor Interactive variant.
When a Content variant is nested inside an `.interactive` element,
the title inherits color from the Interactive's `--interactive-foreground`
and icons switch to `--interactive-foreground-icon`. This is automatic —
no opt-in prop is required.
=========================================================================== */
.opal-content-xl[data-interactive] .opal-content-xl-title {
color: var(--interactive-foreground);
.interactive .opal-content-xl {
color: inherit;
}
.opal-content-xl[data-interactive] .opal-content-xl-icon {
.interactive .opal-content-xl .opal-content-xl-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-lg[data-interactive] .opal-content-lg-title {
color: var(--interactive-foreground);
.interactive .opal-content-lg {
color: inherit;
}
.opal-content-lg[data-interactive] .opal-content-lg-icon {
.interactive .opal-content-lg .opal-content-lg-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-md[data-interactive] .opal-content-md-title {
color: var(--interactive-foreground);
.interactive .opal-content-md {
color: inherit;
}
.opal-content-md[data-interactive] .opal-content-md-icon {
.interactive .opal-content-md .opal-content-md-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-sm[data-interactive] .opal-content-sm-title {
color: var(--interactive-foreground);
.interactive .opal-content-sm {
color: inherit;
}
.opal-content-sm[data-interactive] .opal-content-sm-icon {
.interactive .opal-content-sm .opal-content-sm-icon {
color: var(--interactive-foreground-icon);
}

View File

@@ -6,7 +6,14 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Wraps a string for inline markdown parsing by `Text` and other Opal components. */
export function markdown(content: string): RichStr {
return { __brand: "RichStr", raw: content };
/**
* Wraps strings for inline markdown parsing by `Text` and other Opal components.
*
* Multiple arguments are joined with newlines, so each string renders on its own line:
* ```tsx
* markdown("Line one", "Line two", "Line three")
* ```
*/
export function markdown(...lines: string[]): RichStr {
return { __brand: "RichStr", raw: lines.join("\n") };
}

View File

@@ -229,7 +229,7 @@ export const RenderField: FC<RenderFieldProps> = ({
name={field.name}
title={label}
description={description}
optional={field.optional}
suffix={field.optional ? "optional" : undefined}
>
<InputTextAreaField
name={field.name}

View File

@@ -15,9 +15,8 @@ interface OrientationLayoutProps {
nonInteractive?: boolean;
children?: React.ReactNode;
title: string | RichStr;
titleSuffix?: string;
description?: string | RichStr;
optional?: boolean;
suffix?: "optional" | (string & {});
sizePreset?: "main-content" | "main-ui";
}
@@ -53,18 +52,16 @@ function VerticalInputLayout({
children,
subDescription,
title,
titleSuffix,
description,
optional,
suffix,
sizePreset = "main-content",
}: VerticalLayoutProps) {
const content = (
<Section gap={0.25} alignItems="start">
<Content
title={title}
titleSuffix={titleSuffix}
description={description}
optional={optional}
suffix={suffix}
sizePreset={sizePreset}
variant="section"
/>
@@ -130,9 +127,8 @@ function HorizontalInputLayout({
children,
center,
title,
titleSuffix,
description,
optional,
suffix,
sizePreset = "main-content",
}: HorizontalLayoutProps) {
const content = (
@@ -145,9 +141,8 @@ function HorizontalInputLayout({
<div className="flex flex-col flex-1 min-w-0 self-stretch">
<Content
title={title}
titleSuffix={titleSuffix}
description={description}
optional={optional}
suffix={suffix}
sizePreset={sizePreset}
variant="section"
widthVariant="full"

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { getUserOAuthTokenStatus, initiateOAuthFlow } from "@/lib/oauth/api";
import { useCallback, useEffect, useRef } from "react";
import useSWR from "swr";
import { errorHandlingFetcher, skipRetryOnAuthError } from "@/lib/fetcher";
import { initiateOAuthFlow } from "@/lib/oauth/api";
import { OAuthTokenStatus, ToolSnapshot } from "@/lib/tools/interfaces";
export interface ToolAuthStatus {
@@ -10,29 +12,38 @@ export interface ToolAuthStatus {
}
export function useToolOAuthStatus(agentId?: number) {
const [oauthTokenStatuses, setOauthTokenStatuses] = useState<
OAuthTokenStatus[]
>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchOAuthStatus = useCallback(async () => {
try {
setLoading(true);
setError(null);
const statuses = await getUserOAuthTokenStatus();
setOauthTokenStatuses(statuses);
} catch (err) {
console.error("Error fetching OAuth token statuses:", err);
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
const {
data: oauthTokenStatuses = [],
isLoading: loading,
error: swrError,
mutate,
} = useSWR<OAuthTokenStatus[]>(
"/api/user-oauth-token/status",
errorHandlingFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60_000,
onErrorRetry: skipRetryOnAuthError,
onError: (err) =>
console.error("[useToolOAuthStatus] fetch failed:", err),
}
}, []);
);
const error: string | null = swrError
? swrError instanceof Error
? swrError.message
: "An error occurred"
: null;
// Re-validate when the active agent changes so the UI reflects fresh token
// state for the new agent's tools without waiting for the dedup interval.
const prevAgentIdRef = useRef(agentId);
useEffect(() => {
fetchOAuthStatus();
}, [agentId, fetchOAuthStatus]);
if (prevAgentIdRef.current !== agentId) {
prevAgentIdRef.current = agentId;
mutate();
}
}, [agentId, mutate]);
/**
* Get OAuth status for a specific tool
@@ -98,6 +109,6 @@ export function useToolOAuthStatus(agentId?: number) {
getToolAuthStatus,
authenticateTool,
getToolsNeedingAuth,
refetch: fetchOAuthStatus,
refetch: () => mutate(),
};
}

View File

@@ -12,6 +12,8 @@ import {
Dispatch,
SetStateAction,
} from "react";
import useSWR from "swr";
import { errorHandlingFetcher, skipRetryOnAuthError } from "@/lib/fetcher";
import type {
CategorizedFiles,
Project,
@@ -160,6 +162,21 @@ export function ProjectsProvider({ children }: ProjectsProviderProps) {
const route = useAppRouter();
const settingsContext = useContext(SettingsContext);
// SWR-backed fetch for recent files. Deduplicates across all mounts and
// handles React StrictMode double-invocation without firing duplicate requests.
const { data: recentFilesData, mutate: mutateRecentFiles } = useSWR<
ProjectFile[]
>("/api/user/files/recent", errorHandlingFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60_000,
onErrorRetry: skipRetryOnAuthError,
onError: (err) =>
console.error("[ProjectsContext] recent files fetch failed:", err),
});
// Track whether allRecentFiles has been seeded from the initial server fetch.
// Subsequent updates come through the merge effect below, not a full reset.
const hasInitializedAllRecentFilesRef = useRef(false);
// Use SWR's mutate to refresh projects - returns the new data
const fetchProjects = useCallback(async (): Promise<Project[]> => {
try {
@@ -286,9 +303,8 @@ export function ProjectsProvider({ children }: ProjectsProviderProps) {
}, []);
const refreshRecentFiles = useCallback(async () => {
const files = await getRecentFiles();
setRecentFiles(files);
}, [getRecentFiles]);
await mutateRecentFiles();
}, [mutateRecentFiles]);
const getTempIdMap = (files: File[], optimisticFiles: ProjectFile[]) => {
const tempIdMap = new Map<string, string>();
@@ -521,13 +537,17 @@ export function ProjectsProvider({ children }: ProjectsProviderProps) {
[]
);
// Sync SWR-fetched recent files into local state. On first arrival, seed
// allRecentFiles as well; subsequent updates only touch recentFiles so the
// merge effect below can non-destructively apply them to allRecentFiles.
useEffect(() => {
// Initial load - only fetch recent files since projects come from props
getRecentFiles().then((recent) => {
setRecentFiles(recent);
setAllRecentFiles(recent);
});
}, [getRecentFiles]);
if (!recentFilesData) return;
setRecentFiles(recentFilesData);
if (!hasInitializedAllRecentFilesRef.current) {
setAllRecentFiles(recentFilesData);
hasInitializedAllRecentFilesRef.current = true;
}
}, [recentFilesData]);
useEffect(() => {
setAllRecentFiles((prev) =>

View File

@@ -57,7 +57,7 @@ export default function SidebarTab({
const content = (
<div className="relative">
<Interactive.Stateful
variant="sidebar"
variant={lowlight ? "sidebar-light" : "sidebar-heavy"}
state={selected ? "selected" : "empty"}
onClick={onClick}
type="button"
@@ -90,9 +90,6 @@ export default function SidebarTab({
title={folded ? "" : children}
sizePreset="main-ui"
variant="body"
prominence={
lowlight ? "muted-2x" : selected ? "default" : "muted"
}
widthVariant="full"
paddingVariant="fit"
rightChildren={truncationSpacer}

View File

@@ -1268,7 +1268,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="description"
title="Description"
optional
suffix="optional"
>
<InputTextAreaField
name="description"
@@ -1293,7 +1293,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="instructions"
title="Instructions"
optional
suffix="optional"
description="Add instructions to tailor the response for this agent."
>
<InputTextAreaField
@@ -1306,7 +1306,7 @@ export default function AgentEditorPage({
name="starter_messages"
title="Conversation Starters"
description="Example messages that help users understand what this agent can do and how to interact with it effectively."
optional
suffix="optional"
>
<StarterMessages />
</InputLayouts.Vertical>
@@ -1546,7 +1546,7 @@ export default function AgentEditorPage({
<InputLayouts.Horizontal
name="knowledge_cutoff_date"
title="Knowledge Cutoff Date"
optional
suffix="optional"
description="Documents with a last-updated date prior to this will be ignored."
>
<InputDatePickerField
@@ -1557,7 +1557,7 @@ export default function AgentEditorPage({
<InputLayouts.Horizontal
name="replace_base_system_prompt"
title="Overwrite System Prompt"
titleSuffix="(Not Recommended)"
suffix="(Not Recommended)"
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
>
<SwitchField name="replace_base_system_prompt" />
@@ -1568,7 +1568,7 @@ export default function AgentEditorPage({
<InputLayouts.Vertical
name="reminders"
title="Reminders"
optional
suffix="optional"
>
<InputTextAreaField
name="reminders"

View File

@@ -5,7 +5,8 @@ import { toast } from "@/hooks/useToast";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { cn } from "@/lib/utils";
import { ContentAction } from "@opal/layouts";
import { markdown } from "@opal/utils";
import { Content } from "@opal/layouts";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
@@ -310,110 +311,109 @@ export default function ConnectedHookCard({
!hook.is_active && "!bg-background-neutral-02"
)}
>
<ContentAction
sizePreset="main-ui"
variant="section"
paddingVariant="sm"
icon={HookIcon}
title={hook.name}
titleClassName={!hook.is_active ? "line-through" : undefined}
iconClassName="text-text-04"
description={`Hook Point: ${spec?.display_name ?? hook.hook_point}`}
bottomChildren={
spec?.docs_url ? (
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={HookIcon}
title={!hook.is_active ? markdown(`~~${hook.name}~~`) : hook.name}
description={`Hook Point: ${
spec?.display_name ?? hook.hook_point
}`}
/>
{spec?.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 w-fit font-secondary-body text-text-03"
className="pl-6 flex items-center gap-1"
>
<span className="underline">Documentation</span>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
) : undefined
}
rightChildren={
<Section
flexDirection="column"
alignItems="end"
width="fit"
height="fit"
gap={0}
>
<div className="flex items-center gap-1 p-2">
)}
</div>
<Section
flexDirection="column"
alignItems="end"
width="fit"
height="fit"
gap={0}
>
<div className="flex items-center gap-1 p-2">
{hook.is_active ? (
<>
<Text mainUiAction text03>
Connected
</Text>
<SvgCheckCircle
size={16}
className="text-status-success-05"
/>
</>
) : (
<div
className={cn(
"flex items-center gap-1",
isBusy ? "opacity-50 pointer-events-none" : "cursor-pointer"
)}
onClick={handleActivate}
>
<Text mainUiAction text03>
Reconnect
</Text>
<SvgPlug size={16} className="text-text-03 shrink-0" />
</div>
)}
</div>
<Disabled disabled={isBusy}>
<div className="flex items-center gap-0.5 pl-1 pr-1 pb-1">
{hook.is_active ? (
<>
<Text mainUiAction text03>
Connected
</Text>
<SvgCheckCircle
size={16}
className="text-status-success-05"
/>
</>
) : (
<div
className={cn(
"flex items-center gap-1",
isBusy
? "opacity-50 pointer-events-none"
: "cursor-pointer"
)}
onClick={handleActivate}
>
<Text mainUiAction text03>
Reconnect
</Text>
<SvgPlug size={16} className="text-text-03 shrink-0" />
</div>
)}
</div>
<Disabled disabled={isBusy}>
{/* Plain div instead of Section: Section applies style={{ padding }} inline which
overrides Tailwind padding classes, making per-side padding (pl/pr/pb) ineffective. */}
<div className="flex items-center gap-0.5 pl-1 pr-1 pb-1">
{hook.is_active ? (
<>
<Button
prominence="tertiary"
size="sm"
icon={SvgUnplug}
onClick={() => setDisconnectConfirmOpen(true)}
tooltip="Disconnect Hook"
aria-label="Deactivate hook"
/>
<Button
prominence="tertiary"
size="sm"
icon={SvgRefreshCw}
onClick={handleValidate}
tooltip="Test Connection"
aria-label="Re-validate hook"
/>
</>
) : (
<Button
prominence="tertiary"
size="sm"
icon={SvgTrash}
onClick={() => setDeleteConfirmOpen(true)}
tooltip="Delete"
aria-label="Delete hook"
icon={SvgUnplug}
onClick={() => setDisconnectConfirmOpen(true)}
tooltip="Disconnect Hook"
aria-label="Deactivate hook"
/>
)}
<Button
prominence="tertiary"
size="sm"
icon={SvgRefreshCw}
onClick={handleValidate}
tooltip="Test Connection"
aria-label="Re-validate hook"
/>
</>
) : (
<Button
prominence="tertiary"
size="sm"
icon={SvgSettings}
onClick={onEdit}
tooltip="Manage"
aria-label="Configure hook"
icon={SvgTrash}
onClick={() => setDeleteConfirmOpen(true)}
tooltip="Delete"
aria-label="Delete hook"
/>
</div>
</Disabled>
</Section>
}
/>
)}
<Button
prominence="tertiary"
size="sm"
icon={SvgSettings}
onClick={onEdit}
tooltip="Manage"
aria-label="Configure hook"
/>
</div>
</Disabled>
</Section>
</div>
</Card>
</>
);

View File

@@ -5,7 +5,7 @@ import { useHookSpecs } from "@/hooks/useHookSpecs";
import { useHooks } from "@/hooks/useHooks";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { Button } from "@opal/components";
import { ContentAction } from "@opal/layouts";
import { Content } from "@opal/layouts";
import InputSearch from "@/refresh-components/inputs/InputSearch";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
@@ -17,6 +17,7 @@ import type {
HookPointMeta,
HookResponse,
} from "@/refresh-pages/admin/HooksPage/interfaces";
import { markdown } from "@opal/utils";
// ---------------------------------------------------------------------------
// Main component
@@ -145,37 +146,39 @@ export default function HooksContent() {
gap={0}
className="hover:border-border-02"
>
<ContentAction
sizePreset="main-ui"
variant="section"
paddingVariant="sm"
icon={UnconnectedIcon}
title={spec.display_name}
iconClassName="text-text-04"
description={spec.description}
bottomChildren={
spec.docs_url ? (
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={UnconnectedIcon}
title={spec.display_name}
description={spec.description}
/>
{spec.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 w-fit font-secondary-body text-text-03"
className="pl-6 flex items-center gap-1"
>
<span className="underline">Documentation</span>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
) : undefined
}
rightChildren={
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={() => setConnectSpec(spec)}
>
Connect
</Button>
}
/>
)}
</div>
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={() => setConnectSpec(spec)}
>
Connect
</Button>
</div>
</Card>
);
})}

View File

@@ -216,7 +216,7 @@ function ExistingProviderCard({
icon={getProviderIcon(provider.provider)}
title={provider.name}
description={getProviderDisplayName(provider.provider)}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
tag={isDefault ? { title: "Default", color: "blue" } : undefined}
rightChildren={
@@ -280,7 +280,7 @@ function NewProviderCard({
icon={getProviderIcon(provider.name)}
title={getProviderProductName(provider.name)}
description={getProviderDisplayName(provider.name)}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button
@@ -316,7 +316,7 @@ function NewCustomProviderCard({
icon={getProviderIcon("custom")}
title={getProviderProductName("custom")}
description={getProviderDisplayName("custom")}
sizePreset="main-content"
sizePreset="main-ui"
variant="section"
rightChildren={
<Button

View File

@@ -158,7 +158,7 @@ export default function AddMCPServerModal({
<InputLayouts.Vertical
name="description"
title="Description"
optional
suffix="optional"
>
<InputTextAreaField
name="description"

View File

@@ -127,10 +127,9 @@ export default function AgentCard({ agent }: AgentCardProps) {
{fullAgent && <AgentViewerModal agent={fullAgent} />}
</agentViewerModal.Provider>
<Interactive.Stateless
<Interactive.Simple
onClick={() => agentViewerModal.toggle(true)}
group="group/AgentCard"
variant="none"
>
<Card
padding={0}
@@ -232,7 +231,7 @@ export default function AgentCard({ agent }: AgentCardProps) {
</div>
</div>
</Card>
</Interactive.Stateless>
</Interactive.Simple>
</>
);
}

View File

@@ -29,13 +29,12 @@ export default function DocumentSetCard({
disabled={!disabled || !disabledTooltip}
>
<div className="max-w-[12rem]">
<Interactive.Stateless
<Interactive.Simple
onClick={
disabled || isSelected === undefined
? undefined
: () => onSelectToggle?.(!isSelected)
}
variant="none"
>
<Interactive.Container
data-testid={`document-set-card-${documentSet.id}`}
@@ -64,7 +63,7 @@ export default function DocumentSetCard({
/>
<Spacer horizontal rem={0.5} />
</Interactive.Container>
</Interactive.Stateless>
</Interactive.Simple>
</div>
</SimpleTooltip>
);

View File

@@ -80,7 +80,7 @@ export default function FeedbackModal({
<InputLayouts.Vertical
name="additional_feedback"
title="Provide Additional Details"
optional={feedbackType === "like"}
suffix={feedbackType === "like" ? "optional" : undefined}
>
<InputTextAreaField
name="additional_feedback"

View File

@@ -126,7 +126,7 @@ function BifrostModalInternals({
<InputLayouts.Vertical
name="api_key"
title="API Key"
optional={true}
suffix="optional"
subDescription={markdown(
"Paste your API key from [Bifrost](https://docs.getbifrost.ai/overview) to access your models."
)}

View File

@@ -147,7 +147,7 @@ function LMStudioFormInternals({
name="custom_config.LM_STUDIO_API_KEY"
title="API Key"
subDescription="Optional API key if your LM Studio server requires authentication."
optional
suffix="optional"
>
<PasswordInputTypeInField
name="custom_config.LM_STUDIO_API_KEY"

View File

@@ -103,7 +103,7 @@ export function APIKeyField({
? `Paste your API key from ${providerName} to access your models.`
: "Paste your API key to access your models."
}
optional={optional}
suffix={optional ? "optional" : undefined}
>
<PasswordInputTypeInField name="api_key" placeholder="API Key" />
</InputLayouts.Vertical>

109
widget/package-lock.json generated
View File

@@ -10,7 +10,8 @@
"dependencies": {
"dompurify": "^3.3.2",
"lit": "^3.1.0",
"marked": "^12.0.0"
"marked": "^12.0.0",
"terser": "^5.46.1"
},
"devDependencies": {
"@types/dompurify": "^3.0.0",
@@ -461,6 +462,51 @@
"node": ">=18"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
@@ -859,6 +905,30 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
@@ -1102,6 +1172,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1112,6 +1191,34 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/terser": {
"version": "5.46.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -19,7 +19,8 @@
"dependencies": {
"dompurify": "^3.3.2",
"lit": "^3.1.0",
"marked": "^12.0.0"
"marked": "^12.0.0",
"terser": "^5.46.1"
},
"devDependencies": {
"@types/dompurify": "^3.0.0",

View File

@@ -2,13 +2,12 @@
* Stream Parser - Processes SSE packets and updates state
*/
import { Packet, Message } from "@/types/api-types";
import { Packet, Message, SearchDocument } from "@/types/api-types";
import { ChatMessage } from "@/types/widget-types";
export interface ParsedMessage {
message: ChatMessage;
isComplete: boolean;
citations?: any[];
}
export interface MessageIDs {
@@ -25,7 +24,8 @@ export function processPacket(
currentMessage: ChatMessage | null,
): {
message: ChatMessage | null;
citations?: any[];
citation?: { citation_number: number; document_id: string };
documents?: SearchDocument[];
status?: string;
messageIds?: MessageIDs;
} {
@@ -80,14 +80,14 @@ export function processPacket(
return { message: currentMessage };
case "citation_info":
// Handle citations
if (currentMessage) {
return {
message: currentMessage,
citations: obj.citations,
};
}
return { message: currentMessage };
// Handle individual citation info packet
return {
message: currentMessage,
citation: {
citation_number: obj.citation_number,
document_id: obj.document_id,
},
};
case "search_tool_start":
// Tool is starting - check if it's internet search
@@ -106,9 +106,10 @@ export function processPacket(
};
case "search_tool_documents_delta":
// Search results coming in
// Search results coming in — capture document metadata for citation resolution
return {
message: currentMessage,
documents: obj.documents,
status: "Reading documents...",
};
@@ -125,8 +126,10 @@ export function processPacket(
};
case "open_url_documents":
// Capture documents from URL fetching for citation resolution
return {
message: currentMessage,
documents: obj.documents,
status: "Processing web content...",
};

View File

@@ -522,4 +522,115 @@ export const widgetStyles = css`
color: var(--text-04);
font-weight: 400;
}
/* Inline citation superscripts */
.message-bubble sup {
font-size: 0.65em;
color: var(--theme-primary-05);
font-weight: 700;
opacity: 0.5;
cursor: default;
letter-spacing: -0.02em;
}
/* Citation source row */
.citation-list {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 6px;
margin-top: 10px;
}
.citation-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
padding: 4px 10px 4px 8px;
border-radius: var(--onyx-radius-08);
background: var(--background-neutral-00);
color: var(--text-04);
text-decoration: none;
cursor: pointer;
border: 1px solid var(--border-01);
transition:
border-color 150ms ease,
background 150ms ease;
line-height: 1.2;
font-family: var(--onyx-font-family);
}
.citation-badge .citation-num {
font-size: 11px;
font-weight: 600;
color: var(--text-04);
opacity: 0.45;
flex-shrink: 0;
}
.citation-badge .citation-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
font-size: 11px;
opacity: 0.8;
text-decoration: none;
}
a.citation-badge,
a.citation-badge:visited,
a.citation-badge:active,
a.citation-badge:hover {
text-decoration: none !important;
}
a.citation-badge:hover {
border-color: var(--theme-primary-05);
background: var(--background-neutral-03);
}
span.citation-badge {
cursor: default;
}
.citation-more {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 500;
padding: 4px 10px;
border-radius: var(--onyx-radius-08);
background: none;
color: var(--text-04);
opacity: 0.6;
border: 1px dashed var(--border-01);
cursor: pointer;
font-family: var(--onyx-font-family);
transition:
opacity 150ms ease,
border-color 150ms ease;
}
.citation-more:hover {
opacity: 1;
border-color: var(--theme-primary-05);
}
.citation-list.expanded .citation-more {
display: none;
}
.citation-overflow {
display: none;
flex-wrap: wrap;
gap: 6px;
width: 100%;
}
.citation-list.expanded .citation-overflow {
display: flex;
}
`;

View File

@@ -49,13 +49,14 @@ export interface MessageDelta {
export interface CitationInfo {
type: "citation_info";
citations: Citation[];
citation_number: number;
document_id: string;
}
export interface Citation {
export interface ResolvedCitation {
citation_number: number;
document_id: string;
semantic_identifier: string;
title?: string;
semantic_identifier?: string;
link?: string;
}
@@ -156,7 +157,7 @@ export interface Message {
content: string;
timestamp: number;
isStreaming?: boolean;
citations?: Citation[];
citations?: ResolvedCitation[];
}
export interface ChatSession {

View File

@@ -2,6 +2,8 @@
* Widget-specific types
*/
import { ResolvedCitation } from "@/types/api-types";
export interface WidgetConfig {
// Required
backendUrl: string;
@@ -37,4 +39,5 @@ export interface ChatMessage {
content: string;
timestamp: number;
isStreaming?: boolean;
citations?: ResolvedCitation[];
}

View File

@@ -3,12 +3,13 @@
* Orchestrates launcher/inline modes and manages widget lifecycle
*/
import { LitElement, html } from "lit";
import { LitElement, html, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { WidgetConfig, ChatMessage } from "./types/widget-types";
import { SearchDocument, ResolvedCitation } from "./types/api-types";
import { resolveConfig } from "./config/config";
import { theme } from "./styles/theme";
import { widgetStyles } from "./styles/widget-styles";
@@ -47,6 +48,9 @@ export class OnyxChatWidget extends LitElement {
private config!: WidgetConfig;
private apiService!: ApiService;
private abortController?: AbortController;
// Citation state — plain fields (not @state) since Map mutations don't trigger Lit re-renders
private documentMap = new Map<string, SearchDocument>();
private citationMap = new Map<number, string>();
constructor() {
super();
@@ -182,16 +186,43 @@ export class OnyxChatWidget extends LitElement {
this.isStreaming = false;
this.isLoading = false;
this.streamingStatus = "";
this.documentMap.clear();
this.citationMap.clear();
clearSession();
}
/**
* Render markdown content safely
* Render markdown content safely.
* Strips [[n]](url) citation links before markdown parsing so they render
* as plain [n] text references. Citation badges are rendered separately.
* Renumbers citations to sequential display numbers (1, 2, 3...).
*/
private renderMarkdown(content: string) {
private renderMarkdown(content: string, citations?: ResolvedCitation[]) {
try {
const htmlContent = marked.parse(content, { async: false }) as string;
const sanitizedHTML = DOMPurify.sanitize(htmlContent);
let stripped = content;
if (this.config.includeCitations) {
if (citations?.length) {
// Build a map from backend citation number → sequential display number
const displayMap = new Map<number, number>();
citations.forEach((c, i) => displayMap.set(c.citation_number, i + 1));
// Replace [[n]](url) with superscript-style display number
stripped = stripped.replace(
/\[\[(\d+)\]\]\([^)]*\)/g,
(_match, num) => {
const displayNum = displayMap.get(Number(num));
return displayNum ? `<sup>[${displayNum}]</sup>` : "";
},
);
} else {
// Still streaming or no citations resolved yet — strip raw links
stripped = stripped.replace(/\[\[(\d+)\]\]\([^)]*\)/g, "");
}
}
const htmlContent = marked.parse(stripped, { async: false }) as string;
const sanitizedHTML = DOMPurify.sanitize(htmlContent, {
ADD_TAGS: ["sup"],
});
return unsafeHTML(sanitizedHTML);
} catch (err) {
console.error("Failed to parse markdown:", err);
@@ -199,6 +230,75 @@ export class OnyxChatWidget extends LitElement {
}
}
private static readonly CITATIONS_COLLAPSED_COUNT = 1;
/**
* Render a single citation badge.
*/
private renderCitationBadge(
c: ResolvedCitation,
displayNum: number,
): TemplateResult {
const title = c.semantic_identifier || "Source";
const safeHref =
c.link && /^https?:\/\//i.test(c.link) ? c.link : undefined;
return safeHref
? html`<a
class="citation-badge"
href=${safeHref}
target="_blank"
rel="noopener noreferrer"
title=${title}
><span class="citation-num">${displayNum}</span
><span class="citation-title">${title}</span></a
>`
: html`<span class="citation-badge" title=${title}
><span class="citation-num">${displayNum}</span
><span class="citation-title">${title}</span></span
>`;
}
/**
* Toggle expanded state for a citation list.
*/
private toggleCitationExpand(e: Event): void {
const container = (e.target as HTMLElement).closest(".citation-list");
if (container) {
container.classList.toggle("expanded");
}
}
/**
* Render citation badges for a message.
* Shows first 3 inline, collapses the rest behind a "+N more" toggle.
*/
private renderCitations(
citations?: ResolvedCitation[],
): string | TemplateResult {
if (!citations?.length) return "";
const limit = OnyxChatWidget.CITATIONS_COLLAPSED_COUNT;
const visible = citations.slice(0, limit);
const overflow = citations.slice(limit);
return html`
<div class="citation-list">
${visible.map((c, i) => this.renderCitationBadge(c, i + 1))}
${overflow.length > 0
? html`
<button class="citation-more" @click=${this.toggleCitationExpand}>
+${overflow.length} more
</button>
<div class="citation-overflow">
${overflow.map((c, i) =>
this.renderCitationBadge(c, limit + i + 1),
)}
</div>
`
: ""}
</div>
`;
}
private toggleOpen() {
this.isOpen = !this.isOpen;
}
@@ -301,7 +401,31 @@ export class OnyxChatWidget extends LitElement {
this.streamingStatus = result.status;
}
// Accumulate document metadata for citation resolution
if (result.documents) {
for (const doc of result.documents) {
this.documentMap.set(doc.document_id, doc);
}
}
// Accumulate citation mappings for the current message
if (result.citation) {
this.citationMap.set(
result.citation.citation_number,
result.citation.document_id,
);
}
if (result.message) {
// Reset per-message citation state when a new message starts
if (
result.message.isStreaming &&
result.message.content === "" &&
currentMessage === null
) {
this.citationMap.clear();
}
currentMessage = result.message;
// Apply the backend message ID if we have it and message doesn't have a numeric ID yet
@@ -312,6 +436,22 @@ export class OnyxChatWidget extends LitElement {
currentMessage.id = assistantMessageId;
}
// When message is complete, resolve citations and attach to message
if (!currentMessage.isStreaming && this.citationMap.size > 0) {
const resolved: ResolvedCitation[] = [];
for (const [citNum, docId] of this.citationMap) {
const doc = this.documentMap.get(docId);
resolved.push({
citation_number: citNum,
document_id: docId,
semantic_identifier: doc?.semantic_identifier,
link: doc?.link ?? undefined,
});
}
resolved.sort((a, b) => a.citation_number - b.citation_number);
currentMessage = { ...currentMessage, citations: resolved };
}
// Update or add message
const existingIndex = this.messages.findIndex(
(m) => m.id === currentMessage?.id,
@@ -326,14 +466,12 @@ export class OnyxChatWidget extends LitElement {
this.messages = [...this.messages, currentMessage];
}
// Clear streaming state when message is complete
// Clear streaming state and persist when message is complete
if (!currentMessage.isStreaming) {
this.isStreaming = false;
this.streamingStatus = "";
saveSession(this.chatSessionId, this.messages);
}
// Persist session
saveSession(this.chatSessionId, this.messages);
}
}
} catch (err: any) {
@@ -469,7 +607,10 @@ export class OnyxChatWidget extends LitElement {
<div class="message ${msg.role}">
<div class="message-bubble">
${msg.role === "assistant"
? this.renderMarkdown(msg.content)
? html`${this.renderMarkdown(
msg.content,
msg.citations,
)}${this.renderCitations(msg.citations)}`
: msg.content}
</div>
</div>