Compare commits

..

38 Commits

Author SHA1 Message Date
rohoswagger
7e6545172b fix(cli): address review feedback — context leak, dead code, streaming state
- Call streamCancel before niling to prevent context resource leak
- Call finishAgent unconditionally to fix spinner stuck on non-text responses
- Remove unreachable nil-event guard in handleStreamEvent
- Remove unused InfoMsg and ErrorMsg types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:41:43 -08:00
rohoswagger
96c631faeb fix(cli): only exclude .venv from golangci-lint go.mod discovery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:31:11 -08:00
rohoswagger
7efcbd5e23 fix(cli): compute scroll ceiling dynamically, fix pre-commit go.mod discovery
scrollUp now computes maxScroll on demand via totalLines() instead of
relying on a cached lastMaxScroll field. Removes no-op self-assignment
in appendToken.

Also excludes .venv and hidden dirs from golangci-lint go.mod discovery
to prevent "no go files to analyze" errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:28:16 -08:00
roshan
e25898cd34 Update cli/internal/tui/commands.go
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 14:18:29 -08:00
rohoswagger
c1bbe47416 docs(cli): update SKILL.md to use validate-config command
Replace manual test -s config check with onyx-cli validate-config,
add validate-config to Commands section, update default URL to
https://cloud.onyx.app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
c41dfa48bc fix(cli): add JSON type discriminator and URL validation in onboarding
Wrap --json events with {"type": "...", "event": {...}} so consumers
can distinguish event types without inspecting payload fields.

Validate server URL scheme (http/https) during onboarding setup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
c9b63803bb feat(cli): add validate-config command, fix errcheck lint violations
- Add `onyx-cli validate-config` to check config and test connection
- Fix all errcheck violations caught by expanded golangci-lint hook
- Change default server URL to https://cloud.onyx.app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
0357bf94cd fix(cli): address review feedback — rename to onyx-cli, remove legacy env, fix TUI bugs
- Rename binary from onyx to onyx-cli across root.go, README, SKILL.md, error messages
- Remove DANSWER_API_KEY legacy env var from config, tests, README, SKILL.md
- Change default server URL to https://cloud.onyx.app
- Fix file drop detection: require explicit path prefix (/, ~, ./, ../)
- Fix cmdNew hardcoded viewport height: use m.viewportHeight()
- Fix auto-scroll wiping user scroll position during streaming
- Fix clearDisplay not resetting streaming state
- Fix top border dash count off-by-one in picker
- Fix view() state mutation: move scroll clamping out of render path
- Fix arrow keys falling through to input at picker boundaries
- Cancel in-progress stream when session is resumed
- Improve SKILL.md config check to verify api_key is non-empty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
147be6a100 refactor(cli): address jmelahman review feedback
- Move version/commit vars to main.go with exported cmd.Version/Commit
  (matches ODS pattern)
- Replace init() with constructor functions (newChatCmd, newAskCmd, etc.)
  and explicit AddCommand in root.go
- TestConnection uses /api/me instead of /api/chat/get-user-chat-sessions
- /clear now starts a new chat session (not just viewport clear)
- Extract shared lipgloss styles to internal/util/styles.go
- API key onboarding URL points to /app/settings/accounts-access
  (personal access keys, no admin privilege required)
- Marshal error in ask --json returns error instead of continuing
- Update go.mod to go 1.26.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
a032b2b883 fix(cli): address review feedback — PgUp/PgDown, picker title, ask, go.mod
- Extract viewportHeight() method so PgUp/PgDown scroll distance
  accounts for the dynamic bottom area (menu, file badges)
- Build picker top border manually to avoid ANSI-corrupted rune slicing
- Validate gotStop in JSON mode so incomplete streams fail loudly
- Run go mod tidy to mark golang.org/x/text as direct dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
e1eb2c913a fix(cli): avoid leaking API key in SKILL.md config check
Replace `cat ~/.config/onyx-cli/config.json` with a `test -s` check
so coding agents don't print the API key to stdout when verifying
configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
roshan
edeebef0ac Update .cursor/skills/onyx-cli/SKILL.md
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-03-05 14:06:04 -08:00
rohoswagger
7c773affc0 feat(cli): rename binary to onyx, add agents command and SKILL.md
Rename the CLI binary from `onyx-cli` to `onyx` so the command is
`onyx ask "..."` instead of `onyx-cli ask "..."`. The pip package
name (`onyx-cli`) and config directory (`~/.config/onyx-cli/`) are
unchanged.

Also adds `onyx agents` subcommand for listing available agents and
a SKILL.md for coding agents (Claude Code, Cursor, Codex) to use
the CLI as a tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
10b21e9984 fix(cli): address PR review feedback — security, error handling, and robustness
- Config file permissions: 0o644 → 0o600 (owner-only, protects API key)
- API key input: use term.ReadPassword to hide keystrokes during onboarding
- Config Load(): warn on malformed JSON instead of silent fallback
- Config save failure in onboarding: now fatal instead of continuing
- scanner.Err() checked after stream loop to surface read errors
- Malformed NDJSON returns ErrorEvent instead of silent nil
- ask --json: ErrorEvent now causes non-zero exit code
- ask: channel close without StopEvent treated as unexpected error
- OpenBrowser returns bool so callers report launch failures
- Picker label truncation: rune-based to prevent UTF-8 corruption
- Picker title: replaces border runes instead of inserting (fixes width)
- Empty agent responses: spacer entry cleaned up
- chat.go: remove duplicate error logging (cobra already prints it)
- Fix loop variable shadowing in session resume handler
- prompt(): handle EOF with partial data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
3fa6cd4908 feat(cli): add stop generation on Escape/Ctrl+D and guard against stale events
- Escape and Ctrl+D during streaming now immediately cancel generation,
  render partial markdown, and show "Generation stopped."
- Ctrl+D during streaming cancels first; requires fresh double-press to quit
- Discard stale StreamEventMsg/StreamDoneMsg after cancellation
- Fix info message ordering so it appears after the agent response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
roshan
d55a1e0120 Apply suggestions from code review
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-03-05 14:06:04 -08:00
rohoswagger
81852c05b5 refactor(cli): apply Go best practices and remove dead code
- Fix data race: capture chatSessionID value before goroutine launch
- Replace interface{} with any (Go 1.18+ idiom) throughout codebase
- Use typed FileDescriptorPayload instead of map[string]any for type safety
- Share http.Transport across Client for connection pooling
- Return error from TestConnection instead of (bool, string)
- Return errors from cobra RunE instead of calling os.Exit
- Handle json.Marshal error in ask command
- Replace deprecated strings.Title with golang.org/x/text/cases
- Replace deprecated tea.MouseWheelUp/Down with tea.MouseButtonWheelUp/Down
- Extract duplicated openBrowser into internal/util with zombie process fix
- Replace custom itoa with strconv.Itoa
- Use sorted map keys for citations instead of magic +100 bound
- Remove unused withTempConfig, addDimInfo, scrollToBottom
- Remove duplicate RenderSplashOnboarding call in onboarding
- Add context.Background() to newRequest via NewRequestWithContext
- Lowercase error string per Go convention (ST1005)
- Fix var alignment in ask.go
- Update README: --persona-id → --agent-id, /persona → /agent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
f86d177481 feat(cli): add picker overlay for agents/sessions, improve message styles
- Agent and session selection now opens a centered bordered overlay
  with arrow navigation instead of inline chat messages
- /agent fetches agents fresh from API each time
- /resume without args opens session picker (same as /sessions)
- Merge /sessions and /resume into one flow, remove duplicate menu entry
- Add tiered message styles: info (visible), warning (yellow), error (red)
- Remove /agent and /resume from argCommands so they execute immediately

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
6388c8f7df refactor(cli): rename persona/assistant to agent throughout codebase
Standardize terminology to "agent" in all Go identifiers, user-facing
strings, slash commands, CLI flags, and comments. API wire format
(endpoints, JSON field names) remains unchanged for compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
0b07bb8a83 docs(cli): add scroll shortcuts to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
3d64264477 feat(cli): rewrite CLI in Go with Bubble Tea TUI
Port the Onyx CLI from Python/Textual to Go/Bubble Tea for single-binary
distribution and better performance. Includes full feature parity:
config, streaming, markdown rendering, slash commands, session management,
file upload, scrolling viewport, and onboarding flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
8d8c0873c1 feat(cli): add splash screen, file drop, /clear command, and UX improvements
- Show Onyx ASCII art splash on empty/new chat screens
- Auto-attach files on drag-and-drop (intercept paste events)
- Add /clear command to clear chat display
- Fix /settings URL to /app/settings/general
- Make Ctrl+D, Escape, Ctrl+O priority bindings (work regardless of focus)
- Update README to use uv instead of pip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
669e6c1f9e feat(cli): add slash command menu, session naming, interactive session picker, inline assistant prefix, and citation toggle
- Add slash command dropdown that appears when typing / with filtering, arrow key navigation, Tab/Enter selection
- Auto-name new chat sessions via backend LLM rename API after first message exchange
- Replace static /sessions list with interactive OptionList picker (arrow keys + Enter to resume, Esc to cancel)
- Change AssistantMessage to Horizontal layout with inline dot prefix instead of separate line
- Hide citation sources by default, add Ctrl+O keybinding to toggle visibility
- Fix InputArea/StatusBar overlap by removing dock:bottom from InputArea
- Simplify UserMessage to use dimmed prefix style instead of bordered panel
- Fix pre-existing ruff F541 warnings (extraneous f-string prefixes)
- Update tests for new AssistantMessage container compose cycle and CitationBlock widget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
4f92f24b97 feat(cli): move connection info to status bar, add bottom divider
Move "Connected to <url> · Assistant" from chat display to the status
bar below the input. Add a second divider line below the input row
for visual framing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
55bcc63761 fix(cli): fix input focus blocked by slow API init, add UI tests
The on_mount handler was awaiting list_personas() which blocked the
entire Textual event loop — focus never reached ChatInput until the
HTTP call completed. Now focus is set immediately and API init runs
via run_worker() in the background.

Adds 28 Textual pilot tests covering focus, typing, message submission,
chat display, streaming, status bar, and file badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
617f8bd7b6 refactor(cli): redesign TUI for minimal Claude Code-style aesthetic
Replace RichLog-based chat display with VerticalScroll + individual
message widgets (UserMessage, AssistantMessage, StatusMessage,
ErrorMessage) to eliminate full-history replay on each streaming token.
Remove Header widget, add prompt prefix and separator, simplify status
bar to assistant name + contextual hint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
e833284d9b chore(cli): add .gitignore, remove build artifacts from tracking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
rohoswagger
1c92e4c7a6 feat(cli): add onyx-cli terminal chat interface
Self-contained Python package (pip install onyx-cli) providing a TUI for
chatting with Onyx from the terminal. Communicates purely over HTTP with
zero imports from the backend.

Includes:
- Textual-based chat TUI with streaming markdown responses
- Rich terminal onboarding flow (server URL, API key, connection test)
- NDJSON stream parser for all backend packet types
- Slash commands (/help, /new, /persona, /attach, /sessions, /resume, etc.)
- File upload support
- One-shot mode (onyx-cli ask "question")
- Dual auth headers (Authorization + X-Onyx-Authorization) for proxy compat
- Smart connection diagnostics (detects AWS ALB blocks, proxy issues)
- Unit tests for stream parser (29 tests) and config (14 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:04 -08:00
Jamison Lahman
9d6ce26ea3 fix(fe): show modal body on Safari/desktop (#9035) 2026-03-05 21:35:43 +00:00
roshan
41713d42a2 chore: upgrade golangci-lint to v2.10.1 for Go 1.26 support (#9107)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:22:56 +00:00
roshan
8afc283410 fix(chrome-extension): open login in new tab when session expires (#9091)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 21:18:21 +00:00
Jamison Lahman
b5c873077e chore(devtools): upgrade ods: 0.6.2->0.6.3 (#9105) 2026-03-05 21:04:51 +00:00
Jamison Lahman
20a4dd32eb chore(devtools): pull release branch and support PR # args (#9102)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 12:37:51 -08:00
Jamison Lahman
fde0d44bc1 chore(devtools): upgrade ods to go 1.26 (#9103) 2026-03-05 20:24:57 +00:00
Jamison Lahman
8fd91b6e83 chore(devtools): ods desktop (#9100) 2026-03-05 19:38:02 +00:00
Justin Tahara
8247fdd45b fix(llm): Handle Bedrock tool content in message history without toolConfig (#9063) 2026-03-05 19:06:35 +00:00
Jamison Lahman
8c5859ba4d fix(fe): disable projects modal button unless project is named (#9093) 2026-03-05 10:29:15 -08:00
Jamison Lahman
62ef6f59bb chore(playwright): screenshot tests for user settings pages (#9078) 2026-03-05 08:35:46 -08:00
67 changed files with 5528 additions and 296 deletions

View File

@@ -0,0 +1,161 @@
---
name: onyx-cli
description: Query the Onyx knowledge base using the onyx-cli command. Use when the user wants to search company documents, ask questions about internal knowledge, query connected data sources, or look up information stored in Onyx.
---
# Onyx CLI — Agent Tool
Onyx is an enterprise search and Gen-AI platform that connects to company documents, apps, and people. The `onyx-cli` CLI provides non-interactive commands to query the Onyx knowledge base and list available agents.
## Prerequisites
### 1. Check if installed
```bash
which onyx-cli
```
### 2. Install (if needed)
**Primary — pip:**
```bash
pip install onyx-cli
```
**From source (Go):**
```bash
cd cli && go build -o onyx-cli . && sudo mv onyx-cli /usr/local/bin/
```
### 3. Check if configured
```bash
onyx-cli validate-config
```
This checks the config file exists, API key is present, and tests the server connection via `/api/me`. Exit code 0 on success, non-zero with a descriptive error on failure.
If unconfigured, you have two options:
**Option A — Interactive setup (requires user input):**
```bash
onyx-cli configure
```
This prompts for the Onyx server URL and API key, tests the connection, and saves config.
**Option B — Environment variables (non-interactive, preferred for agents):**
```bash
export ONYX_SERVER_URL="https://your-onyx-server.com" # default: https://cloud.onyx.app
export ONYX_API_KEY="your-api-key"
```
Environment variables override the config file. If these are set, no config file is needed.
| Variable | Required | Description |
|----------|----------|-------------|
| `ONYX_SERVER_URL` | No | Onyx server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
If neither the config file nor environment variables are set, tell the user that `onyx-cli` needs to be configured and ask them to either:
- Run `onyx-cli configure` interactively, or
- Set `ONYX_SERVER_URL` and `ONYX_API_KEY` environment variables
## Commands
### Validate configuration
```bash
onyx-cli validate-config
```
Checks config file exists, API key is present, and tests the server connection. Use this before `ask` or `agents` to confirm the CLI is properly set up.
### List available agents
```bash
onyx-cli agents
```
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured output:
```bash
onyx-cli agents --json
```
Use agent IDs with `ask --agent-id` to query a specific agent.
### Basic query (plain text output)
```bash
onyx-cli ask "What is our company's PTO policy?"
```
Streams the answer as plain text to stdout. Exit code 0 on success, non-zero on error.
### JSON output (structured events)
```bash
onyx-cli ask --json "What authentication methods do we support?"
```
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
| Event Type | Description |
|------------|-------------|
| `MessageDeltaEvent` | Content token — concatenate all `content` fields for the full answer |
| `StopEvent` | Stream complete |
| `ErrorEvent` | Error with `error` message field |
| `SearchStartEvent` | Onyx started searching documents |
| `CitationEvent` | Source citation with `citation_number` and `document_id` |
### Specify an agent
```bash
onyx-cli ask --agent-id 5 "Summarize our Q4 roadmap"
```
Uses a specific Onyx agent/persona instead of the default.
### All flags
| Flag | Type | Description |
|------|------|-------------|
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## When to Use
Use `onyx-cli ask` when:
- The user asks about company-specific information (policies, docs, processes)
- You need to search internal knowledge bases or connected data sources
- The user references Onyx, asks you to "search Onyx", or wants to query their documents
- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources
Do NOT use when:
- The question is about general programming knowledge (use your own knowledge)
- The user is asking about code in the current repository (use grep/read tools)
- The user hasn't mentioned Onyx and the question doesn't require internal company data
## Examples
```bash
# Simple question
onyx-cli ask "What are the steps to deploy to production?"
# Get structured output for parsing
onyx-cli ask --json "List all active API integrations"
# Use a specialized agent
onyx-cli ask --agent-id 3 "What were the action items from last week's standup?"
# Pipe the answer into another command
onyx-cli ask "What is the database schema for users?" | head -20
```

View File

@@ -119,10 +119,11 @@ repos:
]
- repo: https://github.com/golangci/golangci-lint
rev: 9f61b0f53f80672872fced07b6874397c3ed197b # frozen: v2.7.2
rev: 5d1e709b7be35cb2025444e19de266b056b7b7ee # frozen: v2.10.1
hooks:
- id: golangci-lint
entry: bash -c "find tools/ -name go.mod -print0 | xargs -0 -I{} bash -c 'cd \"$(dirname {})\" && golangci-lint run ./...'"
language_version: "1.26.0"
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
# Ruff version.

View File

@@ -36,7 +36,6 @@ from onyx.db.memory import add_memory
from onyx.db.memory import update_memory_at_index
from onyx.db.memory import UserMemoryContext
from onyx.db.models import Persona
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import LLM
from onyx.llm.interfaces import LLMUserIdentity
from onyx.llm.interfaces import ToolChoiceOptions
@@ -84,28 +83,6 @@ def _looks_like_xml_tool_call_payload(text: str | None) -> bool:
)
def _should_keep_bedrock_tool_definitions(
llm: object, simple_chat_history: list[ChatMessageSimple]
) -> bool:
"""Bedrock requires tool config when history includes toolUse/toolResult blocks."""
model_provider = getattr(getattr(llm, "config", None), "model_provider", None)
if model_provider not in {
LlmProviderNames.BEDROCK,
LlmProviderNames.BEDROCK_CONVERSE,
}:
return False
return any(
(
msg.message_type == MessageType.ASSISTANT
and msg.tool_calls
and len(msg.tool_calls) > 0
)
or msg.message_type == MessageType.TOOL_CALL_RESPONSE
for msg in simple_chat_history
)
def _try_fallback_tool_extraction(
llm_step_result: LlmStepResult,
tool_choice: ToolChoiceOptions,
@@ -686,12 +663,7 @@ def run_llm_loop(
elif out_of_cycles or ran_image_gen:
# Last cycle, no tools allowed, just answer!
tool_choice = ToolChoiceOptions.NONE
# Bedrock requires tool config in requests that include toolUse/toolResult history.
final_tools = (
tools
if _should_keep_bedrock_tool_definitions(llm, simple_chat_history)
else []
)
final_tools = []
else:
tool_choice = ToolChoiceOptions.AUTO
final_tools = tools

View File

@@ -92,6 +92,98 @@ def _prompt_to_dicts(prompt: LanguageModelInput) -> list[dict[str, Any]]:
return [prompt.model_dump(exclude_none=True)]
def _normalize_content(raw: Any) -> str:
"""Normalize a message content field to a plain string.
Content can be a string, None, or a list of content-block dicts
(e.g. [{"type": "text", "text": "..."}]).
"""
if raw is None:
return ""
if isinstance(raw, str):
return raw
if isinstance(raw, list):
return "\n".join(
block.get("text", "") if isinstance(block, dict) else str(block)
for block in raw
)
return str(raw)
def _strip_tool_content_from_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Convert tool-related messages to plain text.
Bedrock's Converse API requires toolConfig when messages contain
toolUse/toolResult content blocks. When no tools are provided for the
current request, we must convert any tool-related history into plain text
to avoid the "toolConfig field must be defined" error.
This is the same approach used by _OllamaHistoryMessageFormatter.
"""
result: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
tool_calls = msg.get("tool_calls")
if role == "assistant" and tool_calls:
# Convert structured tool calls to text representation
tool_call_lines = []
for tc in tool_calls:
func = tc.get("function", {})
name = func.get("name", "unknown")
args = func.get("arguments", "{}")
tc_id = tc.get("id", "")
tool_call_lines.append(
f"[Tool Call] name={name} id={tc_id} args={args}"
)
existing_content = _normalize_content(msg.get("content"))
parts = (
[existing_content] + tool_call_lines
if existing_content
else tool_call_lines
)
new_msg = {
"role": "assistant",
"content": "\n".join(parts),
}
result.append(new_msg)
elif role == "tool":
# Convert tool response to user message with text content
tool_call_id = msg.get("tool_call_id", "")
content = _normalize_content(msg.get("content"))
tool_result_text = f"[Tool Result] id={tool_call_id}\n{content}"
# Merge into previous user message if it is also a converted
# tool result to avoid consecutive user messages (Bedrock requires
# strict user/assistant alternation).
if (
result
and result[-1]["role"] == "user"
and "[Tool Result]" in result[-1].get("content", "")
):
result[-1]["content"] += "\n\n" + tool_result_text
else:
result.append({"role": "user", "content": tool_result_text})
else:
result.append(msg)
return result
def _messages_contain_tool_content(messages: list[dict[str, Any]]) -> bool:
"""Check if any messages contain tool-related content blocks."""
for msg in messages:
if msg.get("role") == "tool":
return True
if msg.get("role") == "assistant" and msg.get("tool_calls"):
return True
return False
def _is_vertex_model_rejecting_output_config(model_name: str) -> bool:
normalized_model_name = model_name.lower()
return any(
@@ -404,13 +496,30 @@ class LitellmLLM(LLM):
else nullcontext()
)
with env_ctx:
messages = _prompt_to_dicts(prompt)
# Bedrock's Converse API requires toolConfig when messages
# contain toolUse/toolResult content blocks. When no tools are
# provided for this request but the history contains tool
# content from previous turns, strip it to plain text.
is_bedrock = self._model_provider in {
LlmProviderNames.BEDROCK,
LlmProviderNames.BEDROCK_CONVERSE,
}
if (
is_bedrock
and not tools
and _messages_contain_tool_content(messages)
):
messages = _strip_tool_content_from_messages(messages)
response = litellm.completion(
mock_response=get_llm_mock_response() or MOCK_LLM_RESPONSE,
model=model,
base_url=self._api_base or None,
api_version=self._api_version or None,
custom_llm_provider=self._custom_llm_provider or None,
messages=_prompt_to_dicts(prompt),
messages=messages,
tools=tools,
tool_choice=tool_choice,
stream=stream,

View File

@@ -2,6 +2,7 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user
@@ -9,8 +10,6 @@ from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.db.persona import get_default_assistant
from onyx.db.persona import update_default_assistant_configuration
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.prompts.chat_prompts import DEFAULT_SYSTEM_PROMPT
from onyx.server.features.default_assistant.models import DefaultAssistantConfiguration
from onyx.server.features.default_assistant.models import DefaultAssistantUpdateRequest
@@ -33,7 +32,7 @@ def get_default_assistant_configuration(
"""
persona = get_default_assistant(db_session)
if not persona:
raise OnyxError(OnyxErrorCode.PERSONA_NOT_FOUND, "Default assistant not found")
raise HTTPException(status_code=404, detail="Default assistant not found")
# Extract DB tool IDs from the persona's tools
tool_ids = [tool.id for tool in persona.tools]
@@ -87,5 +86,5 @@ def update_default_assistant(
except ValueError as e:
if "Default assistant not found" in str(e):
raise OnyxError(OnyxErrorCode.PERSONA_NOT_FOUND, str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from sqlalchemy.orm import Session
@@ -18,8 +19,6 @@ from onyx.db.document_set import mark_document_set_as_to_be_deleted
from onyx.db.document_set import update_document_set
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.document_set.models import CheckDocSetPublicRequest
from onyx.server.features.document_set.models import CheckDocSetPublicResponse
from onyx.server.features.document_set.models import DocumentSetCreationRequest
@@ -55,7 +54,7 @@ def create_document_set(
db_session=db_session,
)
except Exception as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
if not DISABLE_VECTOR_DB:
client_app.send_task(
@@ -76,9 +75,9 @@ def patch_document_set(
) -> None:
document_set = get_document_set_by_id(db_session, document_set_update_request.id)
if document_set is None:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
f"Document set {document_set_update_request.id} does not exist",
raise HTTPException(
status_code=404,
detail=f"Document set {document_set_update_request.id} does not exist",
)
fetch_ee_implementation_or_noop(
@@ -98,7 +97,7 @@ def patch_document_set(
user=user,
)
except Exception as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
if not DISABLE_VECTOR_DB:
client_app.send_task(
@@ -117,9 +116,9 @@ def delete_document_set(
) -> None:
document_set = get_document_set_by_id(db_session, document_set_id)
if document_set is None:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
f"Document set {document_set_id} does not exist",
raise HTTPException(
status_code=404,
detail=f"Document set {document_set_id} does not exist",
)
# check if the user has "edit" access to the document set.
@@ -142,7 +141,7 @@ def delete_document_set(
user=user,
)
except Exception as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
if DISABLE_VECTOR_DB:
db_session.refresh(document_set)

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.access.hierarchy_access import get_user_external_group_ids
@@ -11,8 +12,6 @@ from onyx.db.engine.sql_engine import get_session
from onyx.db.hierarchy import get_accessible_hierarchy_nodes_for_source
from onyx.db.models import User
from onyx.db.opensearch_migration import get_opensearch_retrieval_state
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.hierarchy.constants import DOCUMENT_PAGE_SIZE
from onyx.server.features.hierarchy.constants import HIERARCHY_NODE_DOCUMENTS_PATH
from onyx.server.features.hierarchy.constants import HIERARCHY_NODES_LIST_PATH
@@ -44,14 +43,14 @@ router = APIRouter(prefix=HIERARCHY_NODES_PREFIX)
def _require_opensearch(db_session: Session) -> None:
if not ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
raise OnyxError(
OnyxErrorCode.UNAUTHORIZED,
OPENSEARCH_NOT_ENABLED_MESSAGE,
raise HTTPException(
status_code=403,
detail=OPENSEARCH_NOT_ENABLED_MESSAGE,
)
if not get_opensearch_retrieval_state(db_session):
raise OnyxError(
OnyxErrorCode.UNAUTHORIZED,
MIGRATION_STATUS_MESSAGE,
raise HTTPException(
status_code=403,
detail=MIGRATION_STATUS_MESSAGE,
)

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user
@@ -14,8 +15,6 @@ from onyx.db.input_prompt import remove_public_input_prompt
from onyx.db.input_prompt import update_input_prompt
from onyx.db.models import InputPrompt__User
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.input_prompt.models import CreateInputPromptRequest
from onyx.server.features.input_prompt.models import InputPromptSnapshot
from onyx.server.features.input_prompt.models import UpdateInputPromptRequest
@@ -98,7 +97,7 @@ def patch_input_prompt(
except ValueError as e:
error_msg = "Error occurred while updated input prompt"
logger.warn(f"{error_msg}. Stack trace: {e}")
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
raise HTTPException(status_code=404, detail=error_msg)
return InputPromptSnapshot.from_model(updated_input_prompt)
@@ -118,7 +117,7 @@ def delete_input_prompt(
except ValueError as e:
error_msg = "Error occurred while deleting input prompt"
logger.warn(f"{error_msg}. Stack trace: {e}")
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
raise HTTPException(status_code=404, detail=error_msg)
@admin_router.delete("/{input_prompt_id}")
@@ -133,7 +132,7 @@ def delete_public_input_prompt(
except ValueError as e:
error_msg = "Error occurred while deleting input prompt"
logger.warn(f"{error_msg}. Stack trace: {e}")
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
raise HTTPException(status_code=404, detail=error_msg)
@basic_router.post("/{input_prompt_id}/hide")

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
@@ -8,8 +9,6 @@ from onyx.db.models import User
from onyx.db.notification import dismiss_notification
from onyx.db.notification import get_notification_by_id
from onyx.db.notification import get_notifications
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.build.utils import ensure_build_mode_intro_notification
from onyx.server.features.release_notes.utils import (
ensure_release_notes_fresh_and_notify,
@@ -65,10 +64,10 @@ def dismiss_notification_endpoint(
try:
notification = get_notification_by_id(notification_id, user, db_session)
except PermissionError:
raise OnyxError(
OnyxErrorCode.UNAUTHORIZED, "Not authorized to dismiss this notification"
raise HTTPException(
status_code=403, detail="Not authorized to dismiss this notification"
)
except ValueError:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Notification not found")
raise HTTPException(status_code=404, detail="Notification not found")
dismiss_notification(notification, db_session)

View File

@@ -2,6 +2,7 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.oauth_token_manager import OAuthTokenManager
@@ -19,8 +20,6 @@ from onyx.db.oauth_config import get_oauth_configs
from onyx.db.oauth_config import get_tools_by_oauth_config
from onyx.db.oauth_config import update_oauth_config
from onyx.db.oauth_config import upsert_user_oauth_token
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.federated_connectors.oauth_utils import generate_oauth_state
from onyx.federated_connectors.oauth_utils import verify_oauth_state
from onyx.server.features.oauth_config.models import OAuthCallbackResponse
@@ -80,7 +79,7 @@ def create_oauth_config_endpoint(
)
return _oauth_config_to_snapshot(oauth_config, db_session)
except ValueError as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
@admin_router.get("")
@@ -102,8 +101,8 @@ def get_oauth_config_endpoint(
"""Retrieve a single OAuth configuration (admin only)."""
oauth_config = get_oauth_config(oauth_config_id, db_session)
if not oauth_config:
raise OnyxError(
OnyxErrorCode.NOT_FOUND, f"OAuth config with id {oauth_config_id} not found"
raise HTTPException(
status_code=404, detail=f"OAuth config with id {oauth_config_id} not found"
)
return _oauth_config_to_snapshot(oauth_config, db_session)
@@ -132,7 +131,7 @@ def update_oauth_config_endpoint(
)
return _oauth_config_to_snapshot(updated_config, db_session)
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))
@admin_router.delete("/{oauth_config_id}")
@@ -146,7 +145,7 @@ def delete_oauth_config_endpoint(
delete_oauth_config(oauth_config_id, db_session)
return {"message": "OAuth configuration deleted successfully"}
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))
"""User endpoints for OAuth flow"""
@@ -166,9 +165,9 @@ def initiate_oauth_flow(
# Get OAuth config
oauth_config = get_oauth_config(request.oauth_config_id, db_session)
if not oauth_config:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
f"OAuth config with id {request.oauth_config_id} not found",
raise HTTPException(
status_code=404,
detail=f"OAuth config with id {request.oauth_config_id} not found",
)
# Generate state parameter and store in Redis
@@ -207,8 +206,8 @@ def handle_oauth_callback(
# Verify the user_id matches
if str(user.id) != session.user_id:
raise OnyxError(
OnyxErrorCode.UNAUTHORIZED, "User mismatch in OAuth callback"
raise HTTPException(
status_code=403, detail="User mismatch in OAuth callback"
)
# Extract oauth_config_id from session (stored during initiate)
@@ -217,9 +216,9 @@ def handle_oauth_callback(
# Get OAuth config
oauth_config = get_oauth_config(oauth_config_id, db_session)
if not oauth_config:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
f"OAuth config with id {oauth_config_id} not found",
raise HTTPException(
status_code=404,
detail=f"OAuth config with id {oauth_config_id} not found",
)
# Exchange code for token
@@ -263,4 +262,4 @@ def revoke_oauth_token(
delete_user_oauth_token(oauth_config_id, user.id, db_session)
return {"message": "OAuth token revoked successfully"}
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi_users.exceptions import InvalidPasswordException
from sqlalchemy.orm import Session
@@ -10,8 +11,6 @@ from onyx.auth.users import User
from onyx.auth.users import UserManager
from onyx.db.engine.sql_engine import get_session
from onyx.db.users import get_user_by_email
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.password.models import ChangePasswordRequest
from onyx.server.features.password.models import UserResetRequest
from onyx.server.features.password.models import UserResetResponse
@@ -35,10 +34,10 @@ async def change_my_password(
new_password=form_data.new_password,
)
except InvalidPasswordException as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e.reason))
raise HTTPException(status_code=400, detail=str(e.reason))
except Exception as e:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR, f"An unexpected error occurred: {str(e)}"
raise HTTPException(
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
)
@@ -54,7 +53,7 @@ async def admin_reset_user_password(
"""
user = get_user_by_email(user_reset_request.user_email, db_session)
if not user:
raise OnyxError(OnyxErrorCode.USER_NOT_FOUND, "User not found")
raise HTTPException(status_code=404, detail="User not found")
new_password = await user_manager.reset_password_as_admin(user.id)
return UserResetResponse(
user_id=str(user.id),

View File

@@ -2,6 +2,7 @@ from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi import UploadFile
from pydantic import BaseModel
@@ -37,8 +38,6 @@ from onyx.db.persona import update_persona_public_status
from onyx.db.persona import update_persona_shared
from onyx.db.persona import update_persona_visibility
from onyx.db.persona import update_personas_display_priority
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.file_store.file_store import get_default_file_store
from onyx.file_store.models import ChatFileType
from onyx.server.documents.models import PaginatedReturn
@@ -70,9 +69,9 @@ def _validate_user_knowledge_enabled(
if persona_upsert_request.user_file_ids or getattr(
persona_upsert_request, "user_project_ids", None
):
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"User Knowledge is disabled. Cannot {action} assistant with user files or projects.",
raise HTTPException(
status_code=400,
detail=f"User Knowledge is disabled. Cannot {action} assistant with user files or projects.",
)
@@ -89,22 +88,28 @@ def _validate_vector_db_knowledge(
return
if persona_upsert_request.document_set_ids:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Cannot attach document sets to an assistant when "
"the vector database is disabled (DISABLE_VECTOR_DB is set).",
raise HTTPException(
status_code=400,
detail=(
"Cannot attach document sets to an assistant when "
"the vector database is disabled (DISABLE_VECTOR_DB is set)."
),
)
if persona_upsert_request.hierarchy_node_ids:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Cannot attach hierarchy nodes to an assistant when "
"the vector database is disabled (DISABLE_VECTOR_DB is set).",
raise HTTPException(
status_code=400,
detail=(
"Cannot attach hierarchy nodes to an assistant when "
"the vector database is disabled (DISABLE_VECTOR_DB is set)."
),
)
if persona_upsert_request.document_ids:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Cannot attach documents to an assistant when "
"the vector database is disabled (DISABLE_VECTOR_DB is set).",
raise HTTPException(
status_code=400,
detail=(
"Cannot attach documents to an assistant when "
"the vector database is disabled (DISABLE_VECTOR_DB is set)."
),
)
@@ -160,7 +165,7 @@ def patch_user_persona_public_status(
)
except ValueError as e:
logger.exception("Failed to update persona public status")
raise OnyxError(OnyxErrorCode.UNAUTHORIZED, str(e))
raise HTTPException(status_code=403, detail=str(e))
@admin_router.patch("/{persona_id}/featured")
@@ -179,7 +184,7 @@ def patch_persona_featured_status(
)
except ValueError as e:
logger.exception("Failed to update persona featured status")
raise OnyxError(OnyxErrorCode.UNAUTHORIZED, str(e))
raise HTTPException(status_code=403, detail=str(e))
@admin_agents_router.patch("/display-priorities")
@@ -197,7 +202,7 @@ def patch_agents_display_priorities(
)
except ValueError as e:
logger.exception("Failed to update agent display priorities.")
raise OnyxError(OnyxErrorCode.UNAUTHORIZED, str(e))
raise HTTPException(status_code=403, detail=str(e))
@admin_router.get("", tags=PUBLIC_API_TAGS)
@@ -367,9 +372,9 @@ def create_label(
label_model = create_assistant_label(name=label.name, db_session=db)
return PersonaLabelResponse.from_model(label_model)
except IntegrityError:
raise OnyxError(
OnyxErrorCode.DUPLICATE_RESOURCE,
f"Label with name '{label.name}' already exists. Please choose a different name.",
raise HTTPException(
status_code=400,
detail=f"Label with name '{label.name}' already exists. Please choose a different name.",
)
@@ -423,10 +428,10 @@ def share_persona(
)
except PermissionError as e:
logger.exception("Failed to share persona")
raise OnyxError(OnyxErrorCode.UNAUTHORIZED, str(e))
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
logger.exception("Failed to share persona")
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
@basic_router.delete("/{persona_id}", tags=PUBLIC_API_TAGS)

View File

@@ -6,6 +6,7 @@ from fastapi import BackgroundTasks
from fastapi import Depends
from fastapi import File
from fastapi import Form
from fastapi import HTTPException
from fastapi import Response
from fastapi import UploadFile
from pydantic import BaseModel
@@ -28,8 +29,6 @@ from onyx.db.models import UserProject
from onyx.db.persona import get_personas_by_ids
from onyx.db.projects import get_project_token_count
from onyx.db.projects import upload_files_to_user_files_with_indexing
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.projects.models import CategorizedFilesSnapshot
from onyx.server.features.projects.models import ChatSessionRequest
from onyx.server.features.projects.models import TokenCountResponse
@@ -116,7 +115,7 @@ def create_project(
db_session: Session = Depends(get_session),
) -> UserProjectSnapshot:
if name == "":
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "Project name cannot be empty")
raise HTTPException(status_code=400, detail="Project name cannot be empty")
user_id = user.id
project = UserProject(name=name, user_id=user_id)
db_session.add(project)
@@ -160,9 +159,9 @@ def upload_user_files(
except Exception as e:
logger.exception(f"Error uploading files - {type(e).__name__}: {str(e)}")
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Failed to upload files. Please try again or contact support if the issue persists.",
raise HTTPException(
status_code=500,
detail="Failed to upload files. Please try again or contact support if the issue persists.",
)
@@ -179,7 +178,7 @@ def get_project(
.one_or_none()
)
if project is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Project not found")
raise HTTPException(status_code=404, detail="Project not found")
return UserProjectSnapshot.from_model(project)
@@ -223,7 +222,7 @@ def unlink_user_file_from_project(
.one_or_none()
)
if project is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Project not found")
raise HTTPException(status_code=404, detail="Project not found")
user_file = (
db_session.query(UserFile)
@@ -231,7 +230,7 @@ def unlink_user_file_from_project(
.one_or_none()
)
if user_file is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "File not found")
raise HTTPException(status_code=404, detail="File not found")
# Remove the association if it exists
if user_file in project.user_files:
@@ -269,7 +268,7 @@ def link_user_file_to_project(
.one_or_none()
)
if project is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Project not found")
raise HTTPException(status_code=404, detail="Project not found")
user_file = (
db_session.query(UserFile)
@@ -277,7 +276,7 @@ def link_user_file_to_project(
.one_or_none()
)
if user_file is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "File not found")
raise HTTPException(status_code=404, detail="File not found")
if user_file not in project.user_files:
user_file.needs_project_sync = True
@@ -312,7 +311,7 @@ def get_project_instructions(
)
if project is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Project not found")
raise HTTPException(status_code=404, detail="Project not found")
return ProjectInstructionsResponse(instructions=project.instructions)
@@ -341,7 +340,7 @@ def upsert_project_instructions(
.one_or_none()
)
if project is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Project not found")
raise HTTPException(status_code=404, detail="Project not found")
project.instructions = body.instructions
db_session.commit()
@@ -398,7 +397,7 @@ def update_project(
.one_or_none()
)
if project is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Project not found")
raise HTTPException(status_code=404, detail="Project not found")
if body.name is not None:
project.name = body.name
@@ -423,7 +422,7 @@ def delete_project(
.one_or_none()
)
if project is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Project not found")
raise HTTPException(status_code=404, detail="Project not found")
# Unlink chat sessions from this project
for chat in project.chat_sessions:
@@ -456,7 +455,7 @@ def delete_user_file(
.one_or_none()
)
if user_file is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "File not found")
raise HTTPException(status_code=404, detail="File not found")
# Check associations with projects and assistants (personas)
project_names = [project.name for project in user_file.projects]
@@ -516,7 +515,7 @@ def get_user_file(
.one_or_none()
)
if user_file is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "File not found")
raise HTTPException(status_code=404, detail="File not found")
return UserFileSnapshot.from_model(user_file)
@@ -565,7 +564,7 @@ def move_chat_session(
.one_or_none()
)
if chat_session is None:
raise OnyxError(OnyxErrorCode.SESSION_NOT_FOUND, "Chat session not found")
raise HTTPException(status_code=404, detail="Chat session not found")
chat_session.project_id = project_id
db_session.commit()
return Response(status_code=204)
@@ -584,7 +583,7 @@ def remove_chat_session(
.one_or_none()
)
if chat_session is None:
raise OnyxError(OnyxErrorCode.SESSION_NOT_FOUND, "Chat session not found")
raise HTTPException(status_code=404, detail="Chat session not found")
chat_session.project_id = None
db_session.commit()
return Response(status_code=204)
@@ -607,7 +606,7 @@ def get_chat_session_project_token_count(
.one_or_none()
)
if chat_session is None:
raise OnyxError(OnyxErrorCode.SESSION_NOT_FOUND, "Chat session not found")
raise HTTPException(status_code=404, detail="Chat session not found")
total_tokens = get_project_token_count(
project_id=chat_session.project_id,
@@ -637,7 +636,7 @@ def get_chat_session_project_files(
.one_or_none()
)
if chat_session is None:
raise OnyxError(OnyxErrorCode.SESSION_NOT_FOUND, "Chat session not found")
raise HTTPException(status_code=404, detail="Chat session not found")
if chat_session.project_id is None:
return []
@@ -672,7 +671,7 @@ def get_project_total_token_count(
.one_or_none()
)
if project is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Project not found")
raise HTTPException(status_code=404, detail="Project not found")
total_tokens = get_project_token_count(
project_id=project_id,

View File

@@ -2,6 +2,7 @@ from typing import Any
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -18,8 +19,6 @@ from onyx.db.tools import get_tool_by_id
from onyx.db.tools import get_tools
from onyx.db.tools import get_tools_by_ids
from onyx.db.tools import update_tool
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.tool.models import CustomToolCreate
from onyx.server.features.tool.models import CustomToolUpdate
from onyx.server.features.tool.models import ToolSnapshot
@@ -41,16 +40,16 @@ def _validate_tool_definition(definition: dict[str, Any]) -> None:
try:
validate_openapi_schema(definition)
except Exception as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
def _validate_auth_settings(tool_data: CustomToolCreate | CustomToolUpdate) -> None:
if tool_data.passthrough_auth and tool_data.custom_headers:
for header in tool_data.custom_headers:
if header.key.lower() == "authorization":
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Cannot use passthrough auth with custom authorization headers",
raise HTTPException(
status_code=400,
detail="Cannot use passthrough auth with custom authorization headers",
)
@@ -59,12 +58,12 @@ def _get_editable_custom_tool(tool_id: int, db_session: Session, user: User) ->
try:
tool = get_tool_by_id(tool_id, db_session)
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))
if tool.in_code_tool_id is not None:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Built-in tools cannot be modified through this endpoint.",
raise HTTPException(
status_code=400,
detail="Built-in tools cannot be modified through this endpoint.",
)
# Admins can always make changes; non-admins must own the tool.
@@ -72,9 +71,9 @@ def _get_editable_custom_tool(tool_id: int, db_session: Session, user: User) ->
return tool
if tool.user_id is None or tool.user_id != user.id:
raise OnyxError(
OnyxErrorCode.UNAUTHORIZED,
"You can only modify actions that you created.",
raise HTTPException(
status_code=403,
detail="You can only modify actions that you created.",
)
return tool
@@ -138,10 +137,10 @@ def delete_custom_tool(
try:
delete_tool__no_commit(tool_id, db_session)
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
# handles case where tool is still used by an Assistant
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
db_session.commit()
@@ -167,7 +166,7 @@ def update_tools_status(
bulk updates.
"""
if not update_data.tool_ids:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "No tool IDs provided")
raise HTTPException(status_code=400, detail="No tool IDs provided")
tools = get_tools_by_ids(update_data.tool_ids, db_session)
tools_by_id = {tool.id: tool for tool in tools}
@@ -184,8 +183,8 @@ def update_tools_status(
missing_tools.append(tool_id)
if missing_tools:
raise OnyxError(
OnyxErrorCode.NOT_FOUND, f"Tools with IDs {missing_tools} not found"
raise HTTPException(
status_code=404, detail=f"Tools with IDs {missing_tools} not found"
)
db_session.commit()
@@ -243,7 +242,7 @@ def get_custom_tool(
try:
tool = get_tool_by_id(tool_id, db_session)
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))
return ToolSnapshot.from_model(tool)

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
@@ -8,8 +9,6 @@ from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.db.web_search import fetch_active_web_content_provider
from onyx.db.web_search import fetch_active_web_search_provider
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.web_search.models import OpenUrlsToolRequest
from onyx.server.features.web_search.models import OpenUrlsToolResponse
from onyx.server.features.web_search.models import WebSearchToolRequest
@@ -62,9 +61,9 @@ def _get_active_search_provider(
) -> tuple[WebSearchProviderView, WebSearchProvider]:
provider_model = fetch_active_web_search_provider(db_session)
if provider_model is None:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No web search provider configured.",
raise HTTPException(
status_code=400,
detail="No web search provider configured.",
)
provider_view = WebSearchProviderView(
@@ -77,9 +76,9 @@ def _get_active_search_provider(
)
if provider_model.api_key is None:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Web search provider requires an API key.",
raise HTTPException(
status_code=400,
detail="Web search provider requires an API key.",
)
try:
@@ -89,7 +88,7 @@ def _get_active_search_provider(
config=provider_model.config or {},
)
except ValueError as exc:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
return provider_view, provider
@@ -111,9 +110,9 @@ def _get_active_content_provider(
if provider_model.api_key is None:
# TODO - this is not a great error, in fact, this key should not be nullable.
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Web content provider requires an API key.",
raise HTTPException(
status_code=400,
detail="Web content provider requires an API key.",
)
try:
@@ -126,12 +125,12 @@ def _get_active_content_provider(
config=config,
)
except ValueError as exc:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
if provider is None:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Unable to initialize the configured web content provider.",
raise HTTPException(
status_code=400,
detail="Unable to initialize the configured web content provider.",
)
provider_view = WebContentProviderView(
@@ -155,13 +154,12 @@ def _run_web_search(
for query in request.queries:
try:
search_results = provider.search(query)
except OnyxError:
except HTTPException:
raise
except Exception as exc:
logger.exception("Web search provider failed for query '%s'", query)
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Web search provider failed to execute query.",
raise HTTPException(
status_code=502, detail="Web search provider failed to execute query."
) from exc
filtered_results = filter_web_search_results_with_no_title_or_snippet(
@@ -194,12 +192,12 @@ def _open_urls(
docs = filter_web_contents_with_no_title_or_content(
list(provider.contents(urls))
)
except OnyxError:
except HTTPException:
raise
except Exception as exc:
logger.exception("Web content provider failed to fetch URLs")
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY, "Web content provider failed to fetch URLs."
raise HTTPException(
status_code=502, detail="Web content provider failed to fetch URLs."
) from exc
results: list[LlmOpenUrlResult] = []

View File

@@ -317,7 +317,7 @@ oauthlib==3.2.2
# via
# kubernetes
# requests-oauthlib
onyx-devtools==0.6.2
onyx-devtools==0.6.3
# via onyx
openai==2.14.0
# via

View File

@@ -2,7 +2,6 @@
import pytest
from onyx.chat.llm_loop import _should_keep_bedrock_tool_definitions
from onyx.chat.llm_loop import _try_fallback_tool_extraction
from onyx.chat.llm_loop import construct_message_history
from onyx.chat.models import ChatLoadedFile
@@ -14,22 +13,11 @@ from onyx.chat.models import LlmStepResult
from onyx.chat.models import ToolCallSimple
from onyx.configs.constants import MessageType
from onyx.file_store.models import ChatFileType
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import ToolChoiceOptions
from onyx.server.query_and_chat.placement import Placement
from onyx.tools.models import ToolCallKickoff
class _StubConfig:
def __init__(self, model_provider: str) -> None:
self.model_provider = model_provider
class _StubLLM:
def __init__(self, model_provider: str) -> None:
self.config = _StubConfig(model_provider=model_provider)
def create_message(
content: str, message_type: MessageType, token_count: int | None = None
) -> ChatMessageSimple:
@@ -946,37 +934,6 @@ class TestForgottenFileMetadata:
assert "moby_dick.txt" in forgotten.message
class TestBedrockToolConfigGuard:
def test_bedrock_with_tool_history_keeps_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.BEDROCK)
history = [
create_message("Question", MessageType.USER, 5),
create_assistant_with_tool_call("tc_1", "search", 5),
create_tool_response("tc_1", "Tool output", 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is True
def test_bedrock_without_tool_history_does_not_keep_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.BEDROCK)
history = [
create_message("Question", MessageType.USER, 5),
create_message("Answer", MessageType.ASSISTANT, 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is False
def test_non_bedrock_with_tool_history_does_not_keep_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.OPENAI)
history = [
create_message("Question", MessageType.USER, 5),
create_assistant_with_tool_call("tc_1", "search", 5),
create_tool_response("tc_1", "Tool output", 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is False
class TestFallbackToolExtraction:
def _tool_defs(self) -> list[dict]:
return [

View File

@@ -1214,3 +1214,218 @@ def test_multithreaded_invoke_without_custom_config_skips_env_lock() -> None:
# The env lock context manager should never have been called
mock_env_lock.assert_not_called()
# ---- Tests for Bedrock tool content stripping ----
def test_messages_contain_tool_content_with_tool_role() -> None:
from onyx.llm.multi_llm import _messages_contain_tool_content
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "I'll search for that."},
{"role": "tool", "content": "search results", "tool_call_id": "tc_1"},
]
assert _messages_contain_tool_content(messages) is True
def test_messages_contain_tool_content_with_tool_calls() -> None:
from onyx.llm.multi_llm import _messages_contain_tool_content
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
}
],
},
]
assert _messages_contain_tool_content(messages) is True
def test_messages_contain_tool_content_without_tools() -> None:
from onyx.llm.multi_llm import _messages_contain_tool_content
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
assert _messages_contain_tool_content(messages) is False
def test_strip_tool_content_converts_assistant_tool_calls_to_text() -> None:
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Search for cats"},
{
"role": "assistant",
"content": "Let me search.",
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {
"name": "search",
"arguments": '{"query": "cats"}',
},
}
],
},
{
"role": "tool",
"content": "Found 3 results about cats.",
"tool_call_id": "tc_1",
},
{"role": "assistant", "content": "Here are the results."},
]
result = _strip_tool_content_from_messages(messages)
assert len(result) == 4
# First message unchanged
assert result[0] == {"role": "user", "content": "Search for cats"}
# Assistant with tool calls → plain text
assert result[1]["role"] == "assistant"
assert "tool_calls" not in result[1]
assert "Let me search." in result[1]["content"]
assert "[Tool Call]" in result[1]["content"]
assert "search" in result[1]["content"]
assert "tc_1" in result[1]["content"]
# Tool response → user message
assert result[2]["role"] == "user"
assert "[Tool Result]" in result[2]["content"]
assert "tc_1" in result[2]["content"]
assert "Found 3 results about cats." in result[2]["content"]
# Final assistant message unchanged
assert result[3] == {"role": "assistant", "content": "Here are the results."}
def test_strip_tool_content_handles_assistant_with_no_text_content() -> None:
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
}
],
},
]
result = _strip_tool_content_from_messages(messages)
assert result[0]["role"] == "assistant"
assert "[Tool Call]" in result[0]["content"]
assert "tool_calls" not in result[0]
def test_strip_tool_content_passes_through_non_tool_messages() -> None:
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi!"},
]
result = _strip_tool_content_from_messages(messages)
assert result == messages
def test_strip_tool_content_handles_list_content_blocks() -> None:
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{
"role": "assistant",
"content": [{"type": "text", "text": "Searching now."}],
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
}
],
},
{
"role": "tool",
"content": [
{"type": "text", "text": "result A"},
{"type": "text", "text": "result B"},
],
"tool_call_id": "tc_1",
},
]
result = _strip_tool_content_from_messages(messages)
# Assistant: list content flattened + tool call appended
assert result[0]["role"] == "assistant"
assert "Searching now." in result[0]["content"]
assert "[Tool Call]" in result[0]["content"]
assert isinstance(result[0]["content"], str)
# Tool: list content flattened into user message
assert result[1]["role"] == "user"
assert "result A" in result[1]["content"]
assert "result B" in result[1]["content"]
assert isinstance(result[1]["content"], str)
def test_strip_tool_content_merges_consecutive_tool_results() -> None:
"""Bedrock requires strict user/assistant alternation. Multiple parallel
tool results must be merged into a single user message."""
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{"role": "user", "content": "weather and news?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "search_weather", "arguments": "{}"},
},
{
"id": "tc_2",
"type": "function",
"function": {"name": "search_news", "arguments": "{}"},
},
],
},
{"role": "tool", "content": "sunny 72F", "tool_call_id": "tc_1"},
{"role": "tool", "content": "headline news", "tool_call_id": "tc_2"},
{"role": "assistant", "content": "Here are the results."},
]
result = _strip_tool_content_from_messages(messages)
# user, assistant (flattened), user (merged tool results), assistant
assert len(result) == 4
roles = [m["role"] for m in result]
assert roles == ["user", "assistant", "user", "assistant"]
# Both tool results merged into one user message
merged = result[2]["content"]
assert "tc_1" in merged
assert "sunny 72F" in merged
assert "tc_2" in merged
assert "headline news" in merged

3
cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
onyx-cli
cli
onyx.cli

118
cli/README.md Normal file
View File

@@ -0,0 +1,118 @@
# Onyx CLI
A terminal interface for chatting with your [Onyx](https://github.com/onyx-dot-app/onyx) agent. Built with Go using [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI framework.
## Installation
```shell
pip install onyx-cli
```
Or with uv:
```shell
uv pip install onyx-cli
```
## Setup
Run the interactive setup:
```shell
onyx-cli configure
```
This prompts for your Onyx server URL and API key, tests the connection, and saves config to `~/.config/onyx-cli/config.json`.
Environment variables override config file values:
| Variable | Required | Description |
|----------|----------|-------------|
| `ONYX_SERVER_URL` | No | Server base URL (default: `http://localhost:3000`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
## Usage
### Interactive chat (default)
```shell
onyx-cli
```
### One-shot question
```shell
onyx-cli ask "What is our company's PTO policy?"
onyx-cli ask --agent-id 5 "Summarize this topic"
onyx-cli ask --json "Hello"
```
| Flag | Description |
|------|-------------|
| `--agent-id <int>` | Agent ID to use (overrides default) |
| `--json` | Output raw NDJSON events instead of plain text |
### List agents
```shell
onyx-cli agents
onyx-cli agents --json
```
## Commands
| Command | Description |
|---------|-------------|
| `chat` | Launch the interactive chat TUI (default) |
| `ask` | Ask a one-shot question (non-interactive) |
| `agents` | List available agents |
| `configure` | Configure server URL and API key |
## Slash Commands (in TUI)
| Command | Description |
|---------|-------------|
| `/help` | Show help message |
| `/new` | Start a new chat session |
| `/agent` | List and switch agents |
| `/attach <path>` | Attach a file to next message |
| `/sessions` | List recent chat sessions |
| `/clear` | Clear the chat display |
| `/configure` | Re-run connection setup |
| `/connectors` | Open connectors in browser |
| `/settings` | Open settings in browser |
| `/quit` | Exit Onyx CLI |
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Enter` | Send message |
| `Escape` | Cancel current generation |
| `Ctrl+O` | Toggle source citations |
| `Ctrl+D` | Quit (press twice) |
| `Scroll` / `Shift+Up/Down` | Scroll chat history |
| `Page Up` / `Page Down` | Scroll half page |
## Building from Source
Requires [Go 1.24+](https://go.dev/dl/).
```shell
cd cli
go build -o onyx-cli .
```
## Development
```shell
# Run tests
go test ./...
# Build
go build -o onyx-cli .
# Lint
staticcheck ./...
```

63
cli/cmd/agents.go Normal file
View File

@@ -0,0 +1,63 @@
package cmd
import (
"encoding/json"
"fmt"
"text/tabwriter"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/spf13/cobra"
)
func newAgentsCmd() *cobra.Command {
var agentsJSON bool
cmd := &cobra.Command{
Use: "agents",
Short: "List available agents",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if !cfg.IsConfigured() {
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
}
client := api.NewClient(cfg)
agents, err := client.ListAgents()
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
}
if agentsJSON {
data, err := json.MarshalIndent(agents, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal agents: %w", err)
}
fmt.Println(string(data))
return nil
}
if len(agents) == 0 {
fmt.Println("No agents available.")
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "ID\tNAME\tDESCRIPTION")
for _, a := range agents {
desc := a.Description
if len(desc) > 60 {
desc = desc[:57] + "..."
}
_, _ = fmt.Fprintf(w, "%d\t%s\t%s\n", a.ID, a.Name, desc)
}
_ = w.Flush()
return nil
},
}
cmd.Flags().BoolVar(&agentsJSON, "json", false, "Output agents as JSON")
return cmd
}

103
cli/cmd/ask.go Normal file
View File

@@ -0,0 +1,103 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"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/models"
"github.com/spf13/cobra"
)
func newAskCmd() *cobra.Command {
var (
askAgentID int
askJSON bool
)
cmd := &cobra.Command{
Use: "ask [question]",
Short: "Ask a one-shot question (non-interactive)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if !cfg.IsConfigured() {
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
}
question := args[0]
agentID := cfg.DefaultAgentID
if cmd.Flags().Changed("agent-id") {
agentID = askAgentID
}
client := api.NewClient(cfg)
parentID := -1
ch := client.SendMessageStream(
context.Background(),
question,
nil,
agentID,
&parentID,
nil,
)
var lastErr error
gotStop := false
for event := range ch {
if askJSON {
wrapped := struct {
Type string `json:"type"`
Event models.StreamEvent `json:"event"`
}{
Type: event.EventType(),
Event: event,
}
data, err := json.Marshal(wrapped)
if err != nil {
return fmt.Errorf("error marshaling event: %w", err)
}
fmt.Println(string(data))
if _, ok := event.(models.ErrorEvent); ok {
lastErr = fmt.Errorf("%s", event.(models.ErrorEvent).Error)
}
if _, ok := event.(models.StopEvent); ok {
gotStop = true
}
continue
}
switch e := event.(type) {
case models.MessageDeltaEvent:
fmt.Print(e.Content)
case models.ErrorEvent:
return fmt.Errorf("%s", e.Error)
case models.StopEvent:
fmt.Println()
return nil
}
}
if lastErr != nil {
return lastErr
}
if !gotStop {
if !askJSON {
fmt.Println()
}
return fmt.Errorf("stream ended unexpectedly")
}
if !askJSON {
fmt.Println()
}
return nil
},
}
cmd.Flags().IntVar(&askAgentID, "agent-id", 0, "Agent ID to use")
cmd.Flags().BoolVar(&askJSON, "json", false, "Output raw JSON events")
// Suppress cobra's default error/usage on RunE errors
return cmd
}

33
cli/cmd/chat.go Normal file
View File

@@ -0,0 +1,33 @@
package cmd
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/onboarding"
"github.com/onyx-dot-app/onyx/cli/internal/tui"
"github.com/spf13/cobra"
)
func newChatCmd() *cobra.Command {
return &cobra.Command{
Use: "chat",
Short: "Launch the interactive chat TUI (default)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
// First-run: onboarding
if !config.ConfigExists() || !cfg.IsConfigured() {
result := onboarding.Run(&cfg)
if result == nil {
return nil
}
cfg = *result
}
m := tui.NewModel(cfg)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err := p.Run()
return err
},
}
}

19
cli/cmd/configure.go Normal file
View File

@@ -0,0 +1,19 @@
package cmd
import (
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/onboarding"
"github.com/spf13/cobra"
)
func newConfigureCmd() *cobra.Command {
return &cobra.Command{
Use: "configure",
Short: "Configure server URL and API key",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
onboarding.Run(&cfg)
return nil
},
}
}

40
cli/cmd/root.go Normal file
View File

@@ -0,0 +1,40 @@
// Package cmd implements Cobra CLI commands for the Onyx CLI.
package cmd
import "github.com/spf13/cobra"
// Version and Commit are set via ldflags at build time.
var (
Version string
Commit string
)
func fullVersion() string {
if Commit != "" && Commit != "none" && len(Commit) > 7 {
return Version + " (" + Commit[:7] + ")"
}
return Version
}
// Execute creates and runs the root command.
func Execute() error {
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(),
}
// Register subcommands
chatCmd := newChatCmd()
rootCmd.AddCommand(chatCmd)
rootCmd.AddCommand(newAskCmd())
rootCmd.AddCommand(newAgentsCmd())
rootCmd.AddCommand(newConfigureCmd())
rootCmd.AddCommand(newValidateConfigCmd())
// Default command is chat
rootCmd.RunE = chatCmd.RunE
return rootCmd.Execute()
}

41
cli/cmd/validate.go Normal file
View File

@@ -0,0 +1,41 @@
package cmd
import (
"fmt"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/spf13/cobra"
)
func newValidateConfigCmd() *cobra.Command {
return &cobra.Command{
Use: "validate-config",
Short: "Validate configuration and test server connection",
RunE: func(cmd *cobra.Command, args []string) error {
// Check config file
if !config.ConfigExists() {
return fmt.Errorf("config file not found at %s\n Run 'onyx-cli configure' to set up", config.ConfigFilePath())
}
cfg := config.Load()
// Check API key
if !cfg.IsConfigured() {
return fmt.Errorf("API key is missing\n Run 'onyx-cli configure' to set up")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", config.ConfigFilePath())
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server: %s\n", cfg.ServerURL)
// Test connection
client := api.NewClient(cfg)
if err := client.TestConnection(); err != nil {
return fmt.Errorf("connection failed: %w", err)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Status: connected and authenticated")
return nil
},
}
}

45
cli/go.mod Normal file
View File

@@ -0,0 +1,45 @@
module github.com/onyx-dot-app/onyx/cli
go 1.26.0
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.22.0
golang.org/x/text v0.34.0
)
require (
github.com/alecthomas/chroma/v2 v2.14.0 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.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/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/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.27.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)

94
cli/go.sum Normal file
View File

@@ -0,0 +1,94 @@
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/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/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/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/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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
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/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/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=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
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/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/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.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

279
cli/internal/api/client.go Normal file
View File

@@ -0,0 +1,279 @@
// Package api provides the HTTP client for communicating with the Onyx server.
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/models"
)
// Client is the Onyx API client.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client // default 30s timeout for quick requests
longHTTPClient *http.Client // 5min timeout for streaming/uploads
}
// NewClient creates a new API client from config.
func NewClient(cfg config.OnyxCliConfig) *Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
return &Client{
baseURL: strings.TrimRight(cfg.ServerURL, "/"),
apiKey: cfg.APIKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
longHTTPClient: &http.Client{
Timeout: 5 * time.Minute,
Transport: transport,
},
}
}
// UpdateConfig replaces the client's config.
func (c *Client) UpdateConfig(cfg config.OnyxCliConfig) {
c.baseURL = strings.TrimRight(cfg.ServerURL, "/")
c.apiKey = cfg.APIKey
}
func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(context.Background(), method, c.baseURL+path, body)
if err != nil {
return nil, err
}
if c.apiKey != "" {
bearer := "Bearer " + c.apiKey
req.Header.Set("Authorization", bearer)
req.Header.Set("X-Onyx-Authorization", bearer)
}
return req, nil
}
func (c *Client) doJSON(method, path string, reqBody any, result any) error {
var body io.Reader
if reqBody != nil {
data, err := json.Marshal(reqBody)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := c.newRequest(method, path, body)
if err != nil {
return err
}
if reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return &OnyxAPIError{StatusCode: resp.StatusCode, Detail: string(respBody)}
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
// TestConnection checks if the server is reachable and credentials are valid.
// Returns nil on success, or an error with a descriptive message on failure.
func (c *Client) TestConnection() error {
// Step 1: Basic reachability
req, err := c.newRequest("GET", "/", nil)
if err != nil {
return fmt.Errorf("cannot connect to %s: %w", c.baseURL, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cannot connect to %s — is the server running?", c.baseURL)
}
_ = resp.Body.Close()
serverHeader := strings.ToLower(resp.Header.Get("Server"))
if resp.StatusCode == 403 {
if strings.Contains(serverHeader, "awselb") || strings.Contains(serverHeader, "amazons3") {
return fmt.Errorf("blocked by AWS load balancer (HTTP 403 on all requests).\n Your IP address may not be in the ALB's security group or WAF allowlist")
}
return fmt.Errorf("HTTP 403 on base URL — the server is blocking all traffic.\n This is likely a firewall, WAF, or IP allowlist restriction")
}
// Step 2: Authenticated check
req2, err := c.newRequest("GET", "/api/me", nil)
if err != nil {
return fmt.Errorf("server reachable but API error: %w", err)
}
resp2, err := c.longHTTPClient.Do(req2)
if err != nil {
return fmt.Errorf("server reachable but API error: %w", err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode == 200 {
return nil
}
bodyBytes, _ := io.ReadAll(io.LimitReader(resp2.Body, 300))
body := string(bodyBytes)
isHTML := strings.HasPrefix(strings.TrimSpace(body), "<")
respServer := strings.ToLower(resp2.Header.Get("Server"))
if resp2.StatusCode == 401 || resp2.StatusCode == 403 {
if isHTML || strings.Contains(respServer, "awselb") {
return fmt.Errorf("HTTP %d from a reverse proxy (not the Onyx backend).\n Check your deployment's ingress / proxy configuration", resp2.StatusCode)
}
if resp2.StatusCode == 401 {
return fmt.Errorf("invalid API key or token.\n %s", body)
}
return fmt.Errorf("access denied — check that the API key is valid.\n %s", body)
}
detail := fmt.Sprintf("HTTP %d", resp2.StatusCode)
if body != "" {
detail += fmt.Sprintf("\n Response: %s", body)
}
return fmt.Errorf("%s", detail)
}
// ListAgents returns visible agents.
func (c *Client) ListAgents() ([]models.AgentSummary, error) {
var raw []models.AgentSummary
if err := c.doJSON("GET", "/api/persona", nil, &raw); err != nil {
return nil, err
}
var result []models.AgentSummary
for _, p := range raw {
if p.IsVisible {
result = append(result, p)
}
}
return result, nil
}
// ListChatSessions returns recent chat sessions.
func (c *Client) ListChatSessions() ([]models.ChatSessionDetails, error) {
var resp struct {
Sessions []models.ChatSessionDetails `json:"sessions"`
}
if err := c.doJSON("GET", "/api/chat/get-user-chat-sessions", nil, &resp); err != nil {
return nil, err
}
return resp.Sessions, nil
}
// GetChatSession returns full details for a session.
func (c *Client) GetChatSession(sessionID string) (*models.ChatSessionDetailResponse, error) {
var resp models.ChatSessionDetailResponse
if err := c.doJSON("GET", "/api/chat/get-chat-session/"+sessionID, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// RenameChatSession renames a session. If name is empty, the backend auto-generates one.
func (c *Client) RenameChatSession(sessionID string, name *string) (string, error) {
payload := map[string]any{
"chat_session_id": sessionID,
}
if name != nil {
payload["name"] = *name
}
var resp struct {
NewName string `json:"new_name"`
}
if err := c.doJSON("PUT", "/api/chat/rename-chat-session", payload, &resp); err != nil {
return "", err
}
return resp.NewName, nil
}
// UploadFile uploads a file and returns a file descriptor.
func (c *Client) UploadFile(filePath string) (*models.FileDescriptorPayload, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("files", filepath.Base(filePath))
if err != nil {
return nil, err
}
if _, err := io.Copy(part, file); err != nil {
return nil, err
}
_ = writer.Close()
req, err := c.newRequest("POST", "/api/user/projects/file/upload", &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.longHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return nil, &OnyxAPIError{StatusCode: resp.StatusCode, Detail: string(body)}
}
var snapshot models.CategorizedFilesSnapshot
if err := json.NewDecoder(resp.Body).Decode(&snapshot); err != nil {
return nil, err
}
if len(snapshot.UserFiles) == 0 {
return nil, &OnyxAPIError{StatusCode: 400, Detail: "File upload returned no files"}
}
uf := snapshot.UserFiles[0]
return &models.FileDescriptorPayload{
ID: uf.FileID,
Type: uf.ChatFileType,
Name: filepath.Base(filePath),
}, nil
}
// StopChatSession sends a stop signal for a streaming session (best-effort).
func (c *Client) StopChatSession(sessionID string) {
req, err := c.newRequest("POST", "/api/chat/stop-chat-session/"+sessionID, nil)
if err != nil {
return
}
resp, err := c.httpClient.Do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}

View File

@@ -0,0 +1,13 @@
package api
import "fmt"
// OnyxAPIError is returned when an Onyx API call fails.
type OnyxAPIError struct {
StatusCode int
Detail string
}
func (e *OnyxAPIError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Detail)
}

136
cli/internal/api/stream.go Normal file
View File

@@ -0,0 +1,136 @@
package api
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
tea "github.com/charmbracelet/bubbletea"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"github.com/onyx-dot-app/onyx/cli/internal/parser"
)
// StreamEventMsg wraps a StreamEvent for Bubble Tea.
type StreamEventMsg struct {
Event models.StreamEvent
}
// StreamDoneMsg signals the stream has ended.
type StreamDoneMsg struct {
Err error
}
// SendMessageStream starts streaming a chat message response.
// It reads NDJSON lines, parses them, and sends events on the returned channel.
// The goroutine stops when ctx is cancelled or the stream ends.
func (c *Client) SendMessageStream(
ctx context.Context,
message string,
chatSessionID *string,
agentID int,
parentMessageID *int,
fileDescriptors []models.FileDescriptorPayload,
) <-chan models.StreamEvent {
ch := make(chan models.StreamEvent, 64)
go func() {
defer close(ch)
payload := models.SendMessagePayload{
Message: message,
ParentMessageID: parentMessageID,
FileDescriptors: fileDescriptors,
Origin: "api",
IncludeCitations: true,
Stream: true,
}
if payload.FileDescriptors == nil {
payload.FileDescriptors = []models.FileDescriptorPayload{}
}
if chatSessionID != nil {
payload.ChatSessionID = chatSessionID
} else {
payload.ChatSessionInfo = &models.ChatSessionCreationInfo{AgentID: agentID}
}
body, err := json.Marshal(payload)
if err != nil {
ch <- models.ErrorEvent{Error: fmt.Sprintf("marshal error: %v", err), IsRetryable: false}
return
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/chat/send-chat-message", nil)
if err != nil {
ch <- models.ErrorEvent{Error: fmt.Sprintf("request error: %v", err), IsRetryable: false}
return
}
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
bearer := "Bearer " + c.apiKey
req.Header.Set("Authorization", bearer)
req.Header.Set("X-Onyx-Authorization", bearer)
}
resp, err := c.longHTTPClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return // cancelled
}
ch <- models.ErrorEvent{Error: fmt.Sprintf("connection error: %v", err), IsRetryable: true}
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
var respBody [4096]byte
n, _ := resp.Body.Read(respBody[:])
ch <- models.ErrorEvent{
Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(respBody[:n])),
IsRetryable: resp.StatusCode >= 500,
}
return
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
for scanner.Scan() {
if ctx.Err() != nil {
return
}
event := parser.ParseStreamLine(scanner.Text())
if event != nil {
select {
case ch <- event:
case <-ctx.Done():
return
}
}
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
ch <- models.ErrorEvent{Error: fmt.Sprintf("stream read error: %v", err), IsRetryable: true}
}
}()
return ch
}
// WaitForStreamEvent returns a tea.Cmd that reads one event from the channel.
// On channel close, it returns StreamDoneMsg.
func WaitForStreamEvent(ch <-chan models.StreamEvent) tea.Cmd {
return func() tea.Msg {
event, ok := <-ch
if !ok {
return StreamDoneMsg{}
}
return StreamEventMsg{Event: event}
}
}

View File

@@ -0,0 +1,101 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
)
const (
EnvServerURL = "ONYX_SERVER_URL"
EnvAPIKey = "ONYX_API_KEY"
EnvAgentID = "ONYX_PERSONA_ID"
)
// OnyxCliConfig holds the CLI configuration.
type OnyxCliConfig struct {
ServerURL string `json:"server_url"`
APIKey string `json:"api_key"`
DefaultAgentID int `json:"default_persona_id"`
}
// DefaultConfig returns a config with default values.
func DefaultConfig() OnyxCliConfig {
return OnyxCliConfig{
ServerURL: "https://cloud.onyx.app",
APIKey: "",
DefaultAgentID: 0,
}
}
// IsConfigured returns true if the config has an API key.
func (c OnyxCliConfig) IsConfigured() bool {
return c.APIKey != ""
}
// configDir returns ~/.config/onyx-cli
func configDir() string {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "onyx-cli")
}
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".", ".config", "onyx-cli")
}
return filepath.Join(home, ".config", "onyx-cli")
}
// ConfigFilePath returns the full path to the config file.
func ConfigFilePath() string {
return filepath.Join(configDir(), "config.json")
}
// ConfigExists checks if the config file exists on disk.
func ConfigExists() bool {
_, err := os.Stat(ConfigFilePath())
return err == nil
}
// Load reads config from file and applies environment variable overrides.
func Load() OnyxCliConfig {
cfg := DefaultConfig()
data, err := os.ReadFile(ConfigFilePath())
if err == nil {
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
fmt.Fprintf(os.Stderr, "warning: config file %s is malformed: %v (using defaults)\n", ConfigFilePath(), jsonErr)
}
}
// Environment overrides
if v := os.Getenv(EnvServerURL); v != "" {
cfg.ServerURL = v
}
if v := os.Getenv(EnvAPIKey); v != "" {
cfg.APIKey = v
}
if v := os.Getenv(EnvAgentID); v != "" {
if id, err := strconv.Atoi(v); err == nil {
cfg.DefaultAgentID = id
}
}
return cfg
}
// Save writes the config to disk, creating parent directories if needed.
func Save(cfg OnyxCliConfig) error {
dir := configDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(ConfigFilePath(), data, 0o600)
}

View File

@@ -0,0 +1,215 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func clearEnvVars(t *testing.T) {
t.Helper()
for _, key := range []string{EnvServerURL, EnvAPIKey, EnvAgentID} {
t.Setenv(key, "")
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
}
}
func writeConfig(t *testing.T, dir string, data []byte) {
t.Helper()
onyxDir := filepath.Join(dir, "onyx-cli")
if err := os.MkdirAll(onyxDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(onyxDir, "config.json"), data, 0o644); err != nil {
t.Fatal(err)
}
}
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.ServerURL != "https://cloud.onyx.app" {
t.Errorf("expected default server URL, got %s", cfg.ServerURL)
}
if cfg.APIKey != "" {
t.Errorf("expected empty API key, got %s", cfg.APIKey)
}
if cfg.DefaultAgentID != 0 {
t.Errorf("expected default agent ID 0, got %d", cfg.DefaultAgentID)
}
}
func TestIsConfigured(t *testing.T) {
cfg := DefaultConfig()
if cfg.IsConfigured() {
t.Error("empty config should not be configured")
}
cfg.APIKey = "some-key"
if !cfg.IsConfigured() {
t.Error("config with API key should be configured")
}
}
func TestLoadDefaults(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
cfg := Load()
if cfg.ServerURL != "https://cloud.onyx.app" {
t.Errorf("expected default URL, got %s", cfg.ServerURL)
}
if cfg.APIKey != "" {
t.Errorf("expected empty key, got %s", cfg.APIKey)
}
}
func TestLoadFromFile(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
data, _ := json.Marshal(map[string]interface{}{
"server_url": "https://my-onyx.example.com",
"api_key": "test-key-123",
"default_persona_id": 5,
})
writeConfig(t, dir, data)
cfg := Load()
if cfg.ServerURL != "https://my-onyx.example.com" {
t.Errorf("got %s", cfg.ServerURL)
}
if cfg.APIKey != "test-key-123" {
t.Errorf("got %s", cfg.APIKey)
}
if cfg.DefaultAgentID != 5 {
t.Errorf("got %d", cfg.DefaultAgentID)
}
}
func TestLoadCorruptFile(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
writeConfig(t, dir, []byte("not valid json {{{"))
cfg := Load()
if cfg.ServerURL != "https://cloud.onyx.app" {
t.Errorf("expected default URL on corrupt file, got %s", cfg.ServerURL)
}
}
func TestEnvOverrideServerURL(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv(EnvServerURL, "https://env-override.com")
cfg := Load()
if cfg.ServerURL != "https://env-override.com" {
t.Errorf("got %s", cfg.ServerURL)
}
}
func TestEnvOverrideAPIKey(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv(EnvAPIKey, "env-key")
cfg := Load()
if cfg.APIKey != "env-key" {
t.Errorf("got %s", cfg.APIKey)
}
}
func TestEnvOverrideAgentID(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv(EnvAgentID, "42")
cfg := Load()
if cfg.DefaultAgentID != 42 {
t.Errorf("got %d", cfg.DefaultAgentID)
}
}
func TestEnvOverrideInvalidAgentID(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv(EnvAgentID, "not-a-number")
cfg := Load()
if cfg.DefaultAgentID != 0 {
t.Errorf("got %d", cfg.DefaultAgentID)
}
}
func TestEnvOverridesFileValues(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
data, _ := json.Marshal(map[string]interface{}{
"server_url": "https://file-url.com",
"api_key": "file-key",
})
writeConfig(t, dir, data)
t.Setenv(EnvServerURL, "https://env-url.com")
cfg := Load()
if cfg.ServerURL != "https://env-url.com" {
t.Errorf("env should override file, got %s", cfg.ServerURL)
}
if cfg.APIKey != "file-key" {
t.Errorf("file value should be kept, got %s", cfg.APIKey)
}
}
func TestSaveAndReload(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
cfg := OnyxCliConfig{
ServerURL: "https://saved.example.com",
APIKey: "saved-key",
DefaultAgentID: 10,
}
if err := Save(cfg); err != nil {
t.Fatal(err)
}
loaded := Load()
if loaded.ServerURL != "https://saved.example.com" {
t.Errorf("got %s", loaded.ServerURL)
}
if loaded.APIKey != "saved-key" {
t.Errorf("got %s", loaded.APIKey)
}
if loaded.DefaultAgentID != 10 {
t.Errorf("got %d", loaded.DefaultAgentID)
}
}
func TestSaveCreatesParentDirs(t *testing.T) {
clearEnvVars(t)
dir := t.TempDir()
nested := filepath.Join(dir, "deep", "nested")
t.Setenv("XDG_CONFIG_HOME", nested)
if err := Save(OnyxCliConfig{APIKey: "test"}); err != nil {
t.Fatal(err)
}
if !ConfigExists() {
t.Error("config file should exist after save")
}
}

View File

@@ -0,0 +1,193 @@
package models
// StreamEvent is the interface for all parsed stream events.
type StreamEvent interface {
EventType() string
}
// Event type constants matching the Python StreamEventType enum.
const (
EventSessionCreated = "session_created"
EventMessageIDInfo = "message_id_info"
EventStop = "stop"
EventError = "error"
EventMessageStart = "message_start"
EventMessageDelta = "message_delta"
EventSearchStart = "search_tool_start"
EventSearchQueries = "search_tool_queries_delta"
EventSearchDocuments = "search_tool_documents_delta"
EventReasoningStart = "reasoning_start"
EventReasoningDelta = "reasoning_delta"
EventReasoningDone = "reasoning_done"
EventCitationInfo = "citation_info"
EventOpenURLStart = "open_url_start"
EventImageGenStart = "image_generation_start"
EventPythonToolStart = "python_tool_start"
EventCustomToolStart = "custom_tool_start"
EventFileReaderStart = "file_reader_start"
EventDeepResearchPlan = "deep_research_plan_start"
EventDeepResearchDelta = "deep_research_plan_delta"
EventResearchAgentStart = "research_agent_start"
EventIntermediateReport = "intermediate_report_start"
EventIntermediateReportDt = "intermediate_report_delta"
EventUnknown = "unknown"
)
// SessionCreatedEvent is emitted when a new chat session is created.
type SessionCreatedEvent struct {
ChatSessionID string
}
func (e SessionCreatedEvent) EventType() string { return EventSessionCreated }
// MessageIDEvent carries the user and agent message IDs.
type MessageIDEvent struct {
UserMessageID *int
ReservedAgentMessageID int
}
func (e MessageIDEvent) EventType() string { return EventMessageIDInfo }
// StopEvent signals the end of a stream.
type StopEvent struct {
Placement *Placement
StopReason *string
}
func (e StopEvent) EventType() string { return EventStop }
// ErrorEvent signals an error.
type ErrorEvent struct {
Placement *Placement
Error string
StackTrace *string
IsRetryable bool
}
func (e ErrorEvent) EventType() string { return EventError }
// MessageStartEvent signals the beginning of an agent message.
type MessageStartEvent struct {
Placement *Placement
Documents []SearchDoc
}
func (e MessageStartEvent) EventType() string { return EventMessageStart }
// MessageDeltaEvent carries a token of agent content.
type MessageDeltaEvent struct {
Placement *Placement
Content string
}
func (e MessageDeltaEvent) EventType() string { return EventMessageDelta }
// SearchStartEvent signals the beginning of a search.
type SearchStartEvent struct {
Placement *Placement
IsInternetSearch bool
}
func (e SearchStartEvent) EventType() string { return EventSearchStart }
// SearchQueriesEvent carries search queries.
type SearchQueriesEvent struct {
Placement *Placement
Queries []string
}
func (e SearchQueriesEvent) EventType() string { return EventSearchQueries }
// SearchDocumentsEvent carries found documents.
type SearchDocumentsEvent struct {
Placement *Placement
Documents []SearchDoc
}
func (e SearchDocumentsEvent) EventType() string { return EventSearchDocuments }
// ReasoningStartEvent signals the beginning of a reasoning block.
type ReasoningStartEvent struct {
Placement *Placement
}
func (e ReasoningStartEvent) EventType() string { return EventReasoningStart }
// ReasoningDeltaEvent carries reasoning text.
type ReasoningDeltaEvent struct {
Placement *Placement
Reasoning string
}
func (e ReasoningDeltaEvent) EventType() string { return EventReasoningDelta }
// ReasoningDoneEvent signals the end of reasoning.
type ReasoningDoneEvent struct {
Placement *Placement
}
func (e ReasoningDoneEvent) EventType() string { return EventReasoningDone }
// CitationEvent carries citation info.
type CitationEvent struct {
Placement *Placement
CitationNumber int
DocumentID string
}
func (e CitationEvent) EventType() string { return EventCitationInfo }
// ToolStartEvent signals the start of a tool usage.
type ToolStartEvent struct {
Placement *Placement
Type string // The specific event type (e.g. "open_url_start")
ToolName string
}
func (e ToolStartEvent) EventType() string { return e.Type }
// DeepResearchPlanStartEvent signals the start of a deep research plan.
type DeepResearchPlanStartEvent struct {
Placement *Placement
}
func (e DeepResearchPlanStartEvent) EventType() string { return EventDeepResearchPlan }
// DeepResearchPlanDeltaEvent carries deep research plan content.
type DeepResearchPlanDeltaEvent struct {
Placement *Placement
Content string
}
func (e DeepResearchPlanDeltaEvent) EventType() string { return EventDeepResearchDelta }
// ResearchAgentStartEvent signals a research sub-task.
type ResearchAgentStartEvent struct {
Placement *Placement
ResearchTask string
}
func (e ResearchAgentStartEvent) EventType() string { return EventResearchAgentStart }
// IntermediateReportStartEvent signals the start of an intermediate report.
type IntermediateReportStartEvent struct {
Placement *Placement
}
func (e IntermediateReportStartEvent) EventType() string { return EventIntermediateReport }
// IntermediateReportDeltaEvent carries intermediate report content.
type IntermediateReportDeltaEvent struct {
Placement *Placement
Content string
}
func (e IntermediateReportDeltaEvent) EventType() string { return EventIntermediateReportDt }
// UnknownEvent is a catch-all for unrecognized stream data.
type UnknownEvent struct {
Placement *Placement
RawData map[string]any
}
func (e UnknownEvent) EventType() string { return EventUnknown }

View File

@@ -0,0 +1,112 @@
// Package models defines API request/response types for the Onyx CLI.
package models
import "time"
// AgentSummary represents an agent from the API.
type AgentSummary struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsDefaultPersona bool `json:"is_default_persona"`
IsVisible bool `json:"is_visible"`
}
// ChatSessionSummary is a brief session listing.
type ChatSessionSummary struct {
ID string `json:"id"`
Name *string `json:"name"`
AgentID *int `json:"persona_id"`
Created time.Time `json:"time_created"`
}
// ChatSessionDetails is a session with timestamps as strings.
type ChatSessionDetails struct {
ID string `json:"id"`
Name *string `json:"name"`
AgentID *int `json:"persona_id"`
Created string `json:"time_created"`
Updated string `json:"time_updated"`
}
// ChatMessageDetail is a single message in a session.
type ChatMessageDetail struct {
MessageID int `json:"message_id"`
ParentMessage *int `json:"parent_message"`
LatestChildMessage *int `json:"latest_child_message"`
Message string `json:"message"`
MessageType string `json:"message_type"`
TimeSent string `json:"time_sent"`
Error *string `json:"error"`
}
// ChatSessionDetailResponse is the full session detail from the API.
type ChatSessionDetailResponse struct {
ChatSessionID string `json:"chat_session_id"`
Description *string `json:"description"`
AgentID *int `json:"persona_id"`
AgentName *string `json:"persona_name"`
Messages []ChatMessageDetail `json:"messages"`
}
// ChatFileType represents a file type for uploads.
type ChatFileType string
const (
ChatFileImage ChatFileType = "image"
ChatFileDoc ChatFileType = "document"
ChatFilePlainText ChatFileType = "plain_text"
ChatFileCSV ChatFileType = "csv"
)
// FileDescriptorPayload is a file descriptor for send-message requests.
type FileDescriptorPayload struct {
ID string `json:"id"`
Type ChatFileType `json:"type"`
Name string `json:"name,omitempty"`
}
// UserFileSnapshot represents an uploaded file.
type UserFileSnapshot struct {
ID string `json:"id"`
Name string `json:"name"`
FileID string `json:"file_id"`
ChatFileType ChatFileType `json:"chat_file_type"`
}
// CategorizedFilesSnapshot is the response from file upload.
type CategorizedFilesSnapshot struct {
UserFiles []UserFileSnapshot `json:"user_files"`
}
// ChatSessionCreationInfo is included when creating a new session inline.
type ChatSessionCreationInfo struct {
AgentID int `json:"persona_id"`
}
// SendMessagePayload is the request body for POST /api/chat/send-chat-message.
type SendMessagePayload struct {
Message string `json:"message"`
ChatSessionID *string `json:"chat_session_id,omitempty"`
ChatSessionInfo *ChatSessionCreationInfo `json:"chat_session_info,omitempty"`
ParentMessageID *int `json:"parent_message_id"`
FileDescriptors []FileDescriptorPayload `json:"file_descriptors"`
Origin string `json:"origin"`
IncludeCitations bool `json:"include_citations"`
Stream bool `json:"stream"`
}
// SearchDoc represents a document found during search.
type SearchDoc struct {
DocumentID string `json:"document_id"`
SemanticIdentifier string `json:"semantic_identifier"`
Link *string `json:"link"`
SourceType string `json:"source_type"`
}
// Placement indicates where a stream event belongs in the conversation.
type Placement struct {
TurnIndex int `json:"turn_index"`
TabIndex int `json:"tab_index"`
SubTurnIndex *int `json:"sub_turn_index"`
}

View File

@@ -0,0 +1,169 @@
// Package onboarding handles the first-run setup flow for Onyx CLI.
package onboarding
import (
"bufio"
"fmt"
"os"
"strings"
"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/onyx-dot-app/onyx/cli/internal/util"
"golang.org/x/term"
)
// Aliases for shared styles.
var (
boldStyle = util.BoldStyle
dimStyle = util.DimStyle
greenStyle = util.GreenStyle
redStyle = util.RedStyle
yellowStyle = util.YellowStyle
)
func getTermSize() (int, int) {
w, h, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
return 80, 24
}
return w, h
}
// Run executes the interactive onboarding flow.
// Returns the validated config, or nil if the user cancels.
func Run(existing *config.OnyxCliConfig) *config.OnyxCliConfig {
cfg := config.DefaultConfig()
if existing != nil {
cfg = *existing
}
w, h := getTermSize()
fmt.Print(tui.RenderSplashOnboarding(w, h))
fmt.Println()
fmt.Println(" Welcome to " + boldStyle.Render("Onyx CLI") + ".")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
// Server URL
serverURL := prompt(reader, " Onyx server URL", cfg.ServerURL)
if serverURL == "" {
return nil
}
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
fmt.Println(" " + redStyle.Render("Server URL must start with http:// or https://"))
return nil
}
// API Key
fmt.Println()
fmt.Println(" " + dimStyle.Render("Need an API key? Press Enter to open the admin panel in your browser,"))
fmt.Println(" " + dimStyle.Render("or paste your key below."))
fmt.Println()
apiKey := promptSecret(" API key", cfg.APIKey)
if apiKey == "" {
// Open browser to API key page
url := strings.TrimRight(serverURL, "/") + "/app/settings/accounts-access"
fmt.Printf("\n Opening %s ...\n", url)
util.OpenBrowser(url)
fmt.Println(" " + dimStyle.Render("Copy your API key, then paste it here."))
fmt.Println()
apiKey = promptSecret(" API key", "")
if apiKey == "" {
fmt.Println("\n " + redStyle.Render("No API key provided. Exiting."))
return nil
}
}
// Test connection
cfg = config.OnyxCliConfig{
ServerURL: serverURL,
APIKey: apiKey,
DefaultAgentID: cfg.DefaultAgentID,
}
fmt.Println("\n " + yellowStyle.Render("Testing connection..."))
client := api.NewClient(cfg)
if err := client.TestConnection(); err != nil {
fmt.Println(" " + redStyle.Render("Connection failed.") + " " + err.Error())
fmt.Println()
fmt.Println(" " + dimStyle.Render("Run ") + boldStyle.Render("onyx-cli configure") + dimStyle.Render(" to try again."))
return nil
}
if err := config.Save(cfg); err != nil {
fmt.Println(" " + redStyle.Render("Could not save config: "+err.Error()))
return nil
}
fmt.Println(" " + greenStyle.Render("Connected and authenticated."))
fmt.Println()
printQuickStart()
return &cfg
}
func promptSecret(label, defaultVal string) string {
if defaultVal != "" {
fmt.Printf("%s %s: ", label, dimStyle.Render("[hidden]"))
} else {
fmt.Printf("%s: ", label)
}
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // ReadPassword doesn't echo a newline
if err != nil {
return defaultVal
}
line := strings.TrimSpace(string(password))
if line == "" {
return defaultVal
}
return line
}
func prompt(reader *bufio.Reader, label, defaultVal string) string {
if defaultVal != "" {
fmt.Printf("%s %s: ", label, dimStyle.Render("["+defaultVal+"]"))
} else {
fmt.Printf("%s: ", label)
}
line, err := reader.ReadString('\n')
// ReadString may return partial data along with an error (e.g. EOF without newline)
line = strings.TrimSpace(line)
if line != "" {
return line
}
if err != nil {
return defaultVal
}
return defaultVal
}
func printQuickStart() {
fmt.Println(" " + boldStyle.Render("Quick start"))
fmt.Println()
fmt.Println(" Just type to chat with your Onyx agent.")
fmt.Println()
rows := [][2]string{
{"/help", "Show all commands"},
{"/attach", "Attach a file"},
{"/agent", "Switch agent"},
{"/new", "New conversation"},
{"/sessions", "Browse previous chats"},
{"Esc", "Cancel generation"},
{"Ctrl+D", "Quit"},
}
for _, r := range rows {
fmt.Printf(" %-12s %s\n", boldStyle.Render(r[0]), dimStyle.Render(r[1]))
}
fmt.Println()
}

View File

@@ -0,0 +1,248 @@
// Package parser handles NDJSON stream parsing for Onyx chat responses.
package parser
import (
"encoding/json"
"fmt"
"strings"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// ParseStreamLine parses a single NDJSON line into a typed StreamEvent.
// Returns nil for empty lines or unparseable content.
func ParseStreamLine(line string) models.StreamEvent {
line = strings.TrimSpace(line)
if line == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(line), &data); err != nil {
return models.ErrorEvent{Error: fmt.Sprintf("malformed stream data: %v", err), IsRetryable: false}
}
// Case 1: CreateChatSessionID
if _, ok := data["chat_session_id"]; ok {
if _, hasPlacement := data["placement"]; !hasPlacement {
sid, _ := data["chat_session_id"].(string)
return models.SessionCreatedEvent{ChatSessionID: sid}
}
}
// Case 2: MessageResponseIDInfo
if _, ok := data["reserved_assistant_message_id"]; ok {
reservedID := jsonInt(data["reserved_assistant_message_id"])
var userMsgID *int
if v, ok := data["user_message_id"]; ok && v != nil {
id := jsonInt(v)
userMsgID = &id
}
return models.MessageIDEvent{
UserMessageID: userMsgID,
ReservedAgentMessageID: reservedID,
}
}
// Case 3: StreamingError (top-level error without placement)
if _, ok := data["error"]; ok {
if _, hasPlacement := data["placement"]; !hasPlacement {
errStr, _ := data["error"].(string)
var stackTrace *string
if st, ok := data["stack_trace"].(string); ok {
stackTrace = &st
}
isRetryable := true
if v, ok := data["is_retryable"].(bool); ok {
isRetryable = v
}
return models.ErrorEvent{
Error: errStr,
StackTrace: stackTrace,
IsRetryable: isRetryable,
}
}
}
// Case 4: Packet with placement + obj
if rawPlacement, ok := data["placement"]; ok {
if rawObj, ok := data["obj"]; ok {
placement := parsePlacement(rawPlacement)
obj, _ := rawObj.(map[string]any)
if obj == nil {
return models.UnknownEvent{Placement: placement, RawData: data}
}
return parsePacketObj(obj, placement)
}
}
// Fallback
return models.UnknownEvent{RawData: data}
}
func parsePlacement(raw interface{}) *models.Placement {
m, ok := raw.(map[string]any)
if !ok {
return nil
}
p := &models.Placement{
TurnIndex: jsonInt(m["turn_index"]),
TabIndex: jsonInt(m["tab_index"]),
}
if v, ok := m["sub_turn_index"]; ok && v != nil {
st := jsonInt(v)
p.SubTurnIndex = &st
}
return p
}
func parsePacketObj(obj map[string]any, placement *models.Placement) models.StreamEvent {
objType, _ := obj["type"].(string)
switch objType {
case "stop":
var reason *string
if r, ok := obj["stop_reason"].(string); ok {
reason = &r
}
return models.StopEvent{Placement: placement, StopReason: reason}
case "error":
errMsg := "Unknown error"
if e, ok := obj["exception"]; ok {
errMsg = toString(e)
}
return models.ErrorEvent{Placement: placement, Error: errMsg, IsRetryable: true}
case "message_start":
var docs []models.SearchDoc
if rawDocs, ok := obj["final_documents"].([]any); ok {
docs = parseSearchDocs(rawDocs)
}
return models.MessageStartEvent{Placement: placement, Documents: docs}
case "message_delta":
content, _ := obj["content"].(string)
return models.MessageDeltaEvent{Placement: placement, Content: content}
case "search_tool_start":
isInternet, _ := obj["is_internet_search"].(bool)
return models.SearchStartEvent{Placement: placement, IsInternetSearch: isInternet}
case "search_tool_queries_delta":
var queries []string
if raw, ok := obj["queries"].([]any); ok {
for _, q := range raw {
if s, ok := q.(string); ok {
queries = append(queries, s)
}
}
}
return models.SearchQueriesEvent{Placement: placement, Queries: queries}
case "search_tool_documents_delta":
var docs []models.SearchDoc
if rawDocs, ok := obj["documents"].([]any); ok {
docs = parseSearchDocs(rawDocs)
}
return models.SearchDocumentsEvent{Placement: placement, Documents: docs}
case "reasoning_start":
return models.ReasoningStartEvent{Placement: placement}
case "reasoning_delta":
reasoning, _ := obj["reasoning"].(string)
return models.ReasoningDeltaEvent{Placement: placement, Reasoning: reasoning}
case "reasoning_done":
return models.ReasoningDoneEvent{Placement: placement}
case "citation_info":
return models.CitationEvent{
Placement: placement,
CitationNumber: jsonInt(obj["citation_number"]),
DocumentID: jsonString(obj["document_id"]),
}
case "open_url_start", "image_generation_start", "python_tool_start", "file_reader_start":
toolName := strings.ReplaceAll(strings.TrimSuffix(objType, "_start"), "_", " ")
toolName = cases.Title(language.English).String(toolName)
return models.ToolStartEvent{Placement: placement, Type: objType, ToolName: toolName}
case "custom_tool_start":
toolName := jsonString(obj["tool_name"])
if toolName == "" {
toolName = "Custom Tool"
}
return models.ToolStartEvent{Placement: placement, Type: models.EventCustomToolStart, ToolName: toolName}
case "deep_research_plan_start":
return models.DeepResearchPlanStartEvent{Placement: placement}
case "deep_research_plan_delta":
content, _ := obj["content"].(string)
return models.DeepResearchPlanDeltaEvent{Placement: placement, Content: content}
case "research_agent_start":
task, _ := obj["research_task"].(string)
return models.ResearchAgentStartEvent{Placement: placement, ResearchTask: task}
case "intermediate_report_start":
return models.IntermediateReportStartEvent{Placement: placement}
case "intermediate_report_delta":
content, _ := obj["content"].(string)
return models.IntermediateReportDeltaEvent{Placement: placement, Content: content}
default:
return models.UnknownEvent{Placement: placement, RawData: obj}
}
}
func parseSearchDocs(raw []any) []models.SearchDoc {
var docs []models.SearchDoc
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
doc := models.SearchDoc{
DocumentID: jsonString(m["document_id"]),
SemanticIdentifier: jsonString(m["semantic_identifier"]),
SourceType: jsonString(m["source_type"]),
}
if link, ok := m["link"].(string); ok {
doc.Link = &link
}
docs = append(docs, doc)
}
return docs
}
func jsonInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
default:
return 0
}
}
func jsonString(v any) string {
s, _ := v.(string)
return s
}
func toString(v any) string {
switch s := v.(type) {
case string:
return s
default:
b, _ := json.Marshal(v)
return string(b)
}
}

View File

@@ -0,0 +1,419 @@
package parser
import (
"encoding/json"
"testing"
"github.com/onyx-dot-app/onyx/cli/internal/models"
)
func TestEmptyLineReturnsNil(t *testing.T) {
for _, line := range []string{"", " ", "\n"} {
if ParseStreamLine(line) != nil {
t.Errorf("expected nil for %q", line)
}
}
}
func TestInvalidJSONReturnsErrorEvent(t *testing.T) {
for _, line := range []string{"not json", "{broken"} {
event := ParseStreamLine(line)
if event == nil {
t.Errorf("expected ErrorEvent for %q, got nil", line)
continue
}
if _, ok := event.(models.ErrorEvent); !ok {
t.Errorf("expected ErrorEvent for %q, got %T", line, event)
}
}
}
func TestSessionCreated(t *testing.T) {
line := mustJSON(map[string]interface{}{
"chat_session_id": "550e8400-e29b-41d4-a716-446655440000",
})
event := ParseStreamLine(line)
e, ok := event.(models.SessionCreatedEvent)
if !ok {
t.Fatalf("expected SessionCreatedEvent, got %T", event)
}
if e.ChatSessionID != "550e8400-e29b-41d4-a716-446655440000" {
t.Errorf("got %s", e.ChatSessionID)
}
}
func TestMessageIDInfo(t *testing.T) {
line := mustJSON(map[string]interface{}{
"user_message_id": 1,
"reserved_assistant_message_id": 2,
})
event := ParseStreamLine(line)
e, ok := event.(models.MessageIDEvent)
if !ok {
t.Fatalf("expected MessageIDEvent, got %T", event)
}
if e.UserMessageID == nil || *e.UserMessageID != 1 {
t.Errorf("expected user_message_id=1")
}
if e.ReservedAgentMessageID != 2 {
t.Errorf("got %d", e.ReservedAgentMessageID)
}
}
func TestMessageIDInfoNullUserID(t *testing.T) {
line := mustJSON(map[string]interface{}{
"user_message_id": nil,
"reserved_assistant_message_id": 5,
})
event := ParseStreamLine(line)
e, ok := event.(models.MessageIDEvent)
if !ok {
t.Fatalf("expected MessageIDEvent, got %T", event)
}
if e.UserMessageID != nil {
t.Error("expected nil user_message_id")
}
if e.ReservedAgentMessageID != 5 {
t.Errorf("got %d", e.ReservedAgentMessageID)
}
}
func TestTopLevelError(t *testing.T) {
line := mustJSON(map[string]interface{}{
"error": "Rate limit exceeded",
"stack_trace": "...",
"is_retryable": true,
})
event := ParseStreamLine(line)
e, ok := event.(models.ErrorEvent)
if !ok {
t.Fatalf("expected ErrorEvent, got %T", event)
}
if e.Error != "Rate limit exceeded" {
t.Errorf("got %s", e.Error)
}
if e.StackTrace == nil || *e.StackTrace != "..." {
t.Error("expected stack_trace")
}
if !e.IsRetryable {
t.Error("expected retryable")
}
}
func TestTopLevelErrorMinimal(t *testing.T) {
line := mustJSON(map[string]interface{}{
"error": "Something broke",
})
event := ParseStreamLine(line)
e, ok := event.(models.ErrorEvent)
if !ok {
t.Fatalf("expected ErrorEvent, got %T", event)
}
if e.Error != "Something broke" {
t.Errorf("got %s", e.Error)
}
if !e.IsRetryable {
t.Error("expected default retryable=true")
}
}
func makePacket(obj map[string]interface{}, turnIndex, tabIndex int) string {
return mustJSON(map[string]interface{}{
"placement": map[string]interface{}{"turn_index": turnIndex, "tab_index": tabIndex},
"obj": obj,
})
}
func TestStopPacket(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "stop", "stop_reason": "completed"}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.StopEvent)
if !ok {
t.Fatalf("expected StopEvent, got %T", event)
}
if e.StopReason == nil || *e.StopReason != "completed" {
t.Error("expected stop_reason=completed")
}
if e.Placement == nil || e.Placement.TurnIndex != 0 {
t.Error("expected placement")
}
}
func TestStopPacketNoReason(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "stop"}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.StopEvent)
if !ok {
t.Fatalf("expected StopEvent, got %T", event)
}
if e.StopReason != nil {
t.Error("expected nil stop_reason")
}
}
func TestMessageStart(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "message_start"}, 0, 0)
event := ParseStreamLine(line)
_, ok := event.(models.MessageStartEvent)
if !ok {
t.Fatalf("expected MessageStartEvent, got %T", event)
}
}
func TestMessageStartWithDocuments(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "message_start",
"final_documents": []interface{}{
map[string]interface{}{"document_id": "doc1", "semantic_identifier": "Doc 1"},
},
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.MessageStartEvent)
if !ok {
t.Fatalf("expected MessageStartEvent, got %T", event)
}
if len(e.Documents) != 1 || e.Documents[0].DocumentID != "doc1" {
t.Error("expected 1 document with id doc1")
}
}
func TestMessageDelta(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "message_delta", "content": "Hello"}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.MessageDeltaEvent)
if !ok {
t.Fatalf("expected MessageDeltaEvent, got %T", event)
}
if e.Content != "Hello" {
t.Errorf("got %s", e.Content)
}
}
func TestMessageDeltaEmpty(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "message_delta", "content": ""}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.MessageDeltaEvent)
if !ok {
t.Fatalf("expected MessageDeltaEvent, got %T", event)
}
if e.Content != "" {
t.Errorf("expected empty, got %s", e.Content)
}
}
func TestSearchToolStart(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "search_tool_start", "is_internet_search": true,
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.SearchStartEvent)
if !ok {
t.Fatalf("expected SearchStartEvent, got %T", event)
}
if !e.IsInternetSearch {
t.Error("expected internet search")
}
}
func TestSearchToolQueries(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "search_tool_queries_delta",
"queries": []interface{}{"query 1", "query 2"},
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.SearchQueriesEvent)
if !ok {
t.Fatalf("expected SearchQueriesEvent, got %T", event)
}
if len(e.Queries) != 2 || e.Queries[0] != "query 1" {
t.Error("unexpected queries")
}
}
func TestSearchToolDocuments(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "search_tool_documents_delta",
"documents": []interface{}{
map[string]interface{}{"document_id": "d1", "semantic_identifier": "First Doc", "link": "http://example.com"},
map[string]interface{}{"document_id": "d2", "semantic_identifier": "Second Doc"},
},
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.SearchDocumentsEvent)
if !ok {
t.Fatalf("expected SearchDocumentsEvent, got %T", event)
}
if len(e.Documents) != 2 {
t.Errorf("expected 2 docs, got %d", len(e.Documents))
}
if e.Documents[0].Link == nil || *e.Documents[0].Link != "http://example.com" {
t.Error("expected link on first doc")
}
}
func TestReasoningStart(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "reasoning_start"}, 0, 0)
event := ParseStreamLine(line)
if _, ok := event.(models.ReasoningStartEvent); !ok {
t.Fatalf("expected ReasoningStartEvent, got %T", event)
}
}
func TestReasoningDelta(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "reasoning_delta", "reasoning": "Let me think...",
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.ReasoningDeltaEvent)
if !ok {
t.Fatalf("expected ReasoningDeltaEvent, got %T", event)
}
if e.Reasoning != "Let me think..." {
t.Errorf("got %s", e.Reasoning)
}
}
func TestReasoningDone(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "reasoning_done"}, 0, 0)
event := ParseStreamLine(line)
if _, ok := event.(models.ReasoningDoneEvent); !ok {
t.Fatalf("expected ReasoningDoneEvent, got %T", event)
}
}
func TestCitationInfo(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "citation_info", "citation_number": 1, "document_id": "doc_abc",
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.CitationEvent)
if !ok {
t.Fatalf("expected CitationEvent, got %T", event)
}
if e.CitationNumber != 1 || e.DocumentID != "doc_abc" {
t.Errorf("got %d, %s", e.CitationNumber, e.DocumentID)
}
}
func TestOpenURLStart(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "open_url_start"}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.ToolStartEvent)
if !ok {
t.Fatalf("expected ToolStartEvent, got %T", event)
}
if e.Type != "open_url_start" {
t.Errorf("got type %s", e.Type)
}
}
func TestPythonToolStart(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "python_tool_start", "code": "print('hi')",
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.ToolStartEvent)
if !ok {
t.Fatalf("expected ToolStartEvent, got %T", event)
}
if e.ToolName != "Python Tool" {
t.Errorf("got %s", e.ToolName)
}
}
func TestCustomToolStart(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "custom_tool_start", "tool_name": "MyTool",
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.ToolStartEvent)
if !ok {
t.Fatalf("expected ToolStartEvent, got %T", event)
}
if e.ToolName != "MyTool" {
t.Errorf("got %s", e.ToolName)
}
}
func TestDeepResearchPlanDelta(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "deep_research_plan_delta", "content": "Step 1: ...",
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.DeepResearchPlanDeltaEvent)
if !ok {
t.Fatalf("expected DeepResearchPlanDeltaEvent, got %T", event)
}
if e.Content != "Step 1: ..." {
t.Errorf("got %s", e.Content)
}
}
func TestResearchAgentStart(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "research_agent_start", "research_task": "Find info about X",
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.ResearchAgentStartEvent)
if !ok {
t.Fatalf("expected ResearchAgentStartEvent, got %T", event)
}
if e.ResearchTask != "Find info about X" {
t.Errorf("got %s", e.ResearchTask)
}
}
func TestIntermediateReportDelta(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "intermediate_report_delta", "content": "Report text",
}, 0, 0)
event := ParseStreamLine(line)
e, ok := event.(models.IntermediateReportDeltaEvent)
if !ok {
t.Fatalf("expected IntermediateReportDeltaEvent, got %T", event)
}
if e.Content != "Report text" {
t.Errorf("got %s", e.Content)
}
}
func TestUnknownPacketType(t *testing.T) {
line := makePacket(map[string]interface{}{"type": "section_end"}, 0, 0)
event := ParseStreamLine(line)
if _, ok := event.(models.UnknownEvent); !ok {
t.Fatalf("expected UnknownEvent, got %T", event)
}
}
func TestUnknownTopLevel(t *testing.T) {
line := mustJSON(map[string]interface{}{"some_unknown_field": "value"})
event := ParseStreamLine(line)
if _, ok := event.(models.UnknownEvent); !ok {
t.Fatalf("expected UnknownEvent, got %T", event)
}
}
func TestPlacementPreserved(t *testing.T) {
line := makePacket(map[string]interface{}{
"type": "message_delta", "content": "x",
}, 3, 1)
event := ParseStreamLine(line)
e, ok := event.(models.MessageDeltaEvent)
if !ok {
t.Fatalf("expected MessageDeltaEvent, got %T", event)
}
if e.Placement == nil {
t.Fatal("expected placement")
}
if e.Placement.TurnIndex != 3 || e.Placement.TabIndex != 1 {
t.Errorf("got turn=%d tab=%d", e.Placement.TurnIndex, e.Placement.TabIndex)
}
}
func mustJSON(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(b)
}

627
cli/internal/tui/app.go Normal file
View File

@@ -0,0 +1,627 @@
// Package tui implements the Bubble Tea TUI for Onyx CLI.
package tui
import (
"context"
"fmt"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"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/models"
)
// Model is the root Bubble Tea model.
type Model struct {
config config.OnyxCliConfig
client *api.Client
viewport *viewport
input inputModel
status statusBar
width int
height int
// Chat state
chatSessionID *string
agentID int
agentName string
agents []models.AgentSummary
parentMessageID *int
isStreaming bool
streamCancel context.CancelFunc
streamCh <-chan models.StreamEvent
citations map[int]string
attachedFiles []models.FileDescriptorPayload
needsRename bool
agentStarted bool
// Quit state
quitPending bool
splashShown bool
initInputReady bool // true once terminal init responses have passed
}
// NewModel creates a new TUI model.
func NewModel(cfg config.OnyxCliConfig) Model {
client := api.NewClient(cfg)
parentID := -1
return Model{
config: cfg,
client: client,
viewport: newViewport(80),
input: newInputModel(),
status: newStatusBar(),
agentID: cfg.DefaultAgentID,
agentName: "Default",
parentMessageID: &parentID,
citations: make(map[int]string),
}
}
// Init initializes the model.
func (m Model) Init() tea.Cmd {
return loadAgentsCmd(m.client)
}
// Update handles messages.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Filter out terminal query responses (OSC 11 background color, cursor
// position reports, etc.) that arrive as key events with raw escape content.
// These arrive split across multiple key events, so we use a brief window
// after startup to swallow them all.
if keyMsg, ok := msg.(tea.KeyMsg); ok && !m.initInputReady {
// During init, drop ALL key events — they're terminal query responses
_ = keyMsg
return m, nil
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.viewport.setWidth(msg.Width)
m.status.setWidth(msg.Width)
m.input.textInput.Width = msg.Width - 4
if !m.splashShown {
m.splashShown = true
// bottomHeight = sep + input + sep + status = 4 (approx)
viewportHeight := msg.Height - 4
if viewportHeight < 1 {
viewportHeight = msg.Height
}
m.viewport.addSplash(viewportHeight)
// Delay input focus to let terminal query responses flush
return m, tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg {
return inputReadyMsg{}
})
}
return m, nil
case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonWheelUp:
m.viewport.scrollUp(3)
return m, nil
case tea.MouseButtonWheelDown:
m.viewport.scrollDown(3)
return m, nil
}
case tea.KeyMsg:
return m.handleKey(msg)
case submitMsg:
return m.handleSubmit(msg.text)
case fileDropMsg:
return m.handleFileDrop(msg.path)
case InitDoneMsg:
return m.handleInitDone(msg)
case api.StreamEventMsg:
return m.handleStreamEvent(msg)
case api.StreamDoneMsg:
return m.handleStreamDone(msg)
case AgentsLoadedMsg:
return m.handleAgentsLoaded(msg)
case SessionsLoadedMsg:
return m.handleSessionsLoaded(msg)
case SessionResumedMsg:
return m.handleSessionResumed(msg)
case FileUploadedMsg:
return m.handleFileUploaded(msg)
case inputReadyMsg:
m.initInputReady = true
m.input.textInput.Focus()
m.input.textInput.SetValue("")
return m, m.input.textInput.Cursor.BlinkCmd()
case resetQuitMsg:
m.quitPending = false
return m, nil
}
// Only forward messages to the text input after it's been focused
if m.splashShown {
var cmd tea.Cmd
m.input, cmd = m.input.update(msg)
return m, cmd
}
return m, nil
}
// View renders the UI.
// viewportHeight returns the number of visible chat rows, accounting for the
// dynamic bottom area (separator, menu, file badges, input, status bar).
func (m Model) viewportHeight() int {
menuHeight := 0
if m.input.menuVisible {
menuHeight = len(m.input.menuItems)
}
fileHeight := 0
if len(m.input.attachedFiles) > 0 {
fileHeight = 1
}
h := m.height - (1 + menuHeight + fileHeight + 1 + 1 + 1)
if h < 1 {
return 1
}
return h
}
func (m Model) View() string {
if m.width == 0 || m.height == 0 {
return ""
}
separator := lipgloss.NewStyle().Foreground(separatorColor).Render(
strings.Repeat("─", m.width),
)
menuView := m.input.viewMenu(m.width)
viewportHeight := m.viewportHeight()
var parts []string
parts = append(parts, m.viewport.view(viewportHeight))
parts = append(parts, separator)
if menuView != "" {
parts = append(parts, menuView)
}
parts = append(parts, m.input.viewInput())
parts = append(parts, separator)
parts = append(parts, m.status.view())
return strings.Join(parts, "\n")
}
// handleKey processes keyboard input.
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEscape:
// Cancel streaming or close menu
if m.input.menuVisible {
m.input.menuVisible = false
return m, nil
}
if m.isStreaming {
return m.cancelStream()
}
// Dismiss picker
if m.viewport.pickerActive {
m.viewport.pickerActive = false
return m, nil
}
return m, nil
case tea.KeyCtrlD:
// If streaming, cancel first; require a fresh Ctrl+D pair to quit
if m.isStreaming {
return m.cancelStream()
}
if m.quitPending {
return m, tea.Quit
}
m.quitPending = true
m.viewport.addInfo("Press Ctrl+D again to quit.")
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return resetQuitMsg{}
})
case tea.KeyCtrlO:
m.viewport.showSources = !m.viewport.showSources
return m, nil
case tea.KeyEnter:
// If picker is active, handle selection
if m.viewport.pickerActive && len(m.viewport.pickerItems) > 0 {
item := m.viewport.pickerItems[m.viewport.pickerIndex]
m.viewport.pickerActive = false
switch m.viewport.pickerType {
case pickerSession:
return cmdResume(m, item.id)
case pickerAgent:
return cmdSelectAgent(m, item.id)
}
}
case tea.KeyUp:
if m.viewport.pickerActive {
if m.viewport.pickerIndex > 0 {
m.viewport.pickerIndex--
}
return m, nil
}
case tea.KeyDown:
if m.viewport.pickerActive {
if m.viewport.pickerIndex < len(m.viewport.pickerItems)-1 {
m.viewport.pickerIndex++
}
return m, nil
}
case tea.KeyPgUp:
m.viewport.scrollUp(m.viewportHeight() / 2)
return m, nil
case tea.KeyPgDown:
m.viewport.scrollDown(m.viewportHeight() / 2)
return m, nil
case tea.KeyShiftUp:
m.viewport.scrollUp(3)
return m, nil
case tea.KeyShiftDown:
m.viewport.scrollDown(3)
return m, nil
}
// Pass to input
var cmd tea.Cmd
m.input, cmd = m.input.update(msg)
return m, cmd
}
func (m Model) handleSubmit(text string) (tea.Model, tea.Cmd) {
if strings.HasPrefix(text, "/") {
return handleSlashCommand(m, text)
}
return m.sendMessage(text)
}
func (m Model) handleFileDrop(path string) (tea.Model, tea.Cmd) {
return cmdAttach(m, path)
}
func (m Model) cancelStream() (Model, tea.Cmd) {
if m.streamCancel != nil {
m.streamCancel()
}
if m.chatSessionID != nil {
sid := *m.chatSessionID
go m.client.StopChatSession(sid)
}
m, cmd := m.finishStream(nil)
m.viewport.addInfo("Generation stopped.")
return m, cmd
}
func (m Model) sendMessage(message string) (Model, tea.Cmd) {
if m.isStreaming {
return m, nil
}
m.viewport.addUserMessage(message)
m.viewport.startAgent()
// Prepare file descriptors
fileDescs := make([]models.FileDescriptorPayload, len(m.attachedFiles))
copy(fileDescs, m.attachedFiles)
m.attachedFiles = nil
m.input.clearFiles()
m.isStreaming = true
m.agentStarted = false
m.citations = make(map[int]string)
m.status.setStreaming(true)
ctx, cancel := context.WithCancel(context.Background())
m.streamCancel = cancel
ch := m.client.SendMessageStream(
ctx,
message,
m.chatSessionID,
m.agentID,
m.parentMessageID,
fileDescs,
)
m.streamCh = ch
return m, api.WaitForStreamEvent(ch)
}
func (m Model) handleStreamEvent(msg api.StreamEventMsg) (tea.Model, tea.Cmd) {
// Ignore stale events after cancellation
if !m.isStreaming {
return m, nil
}
switch e := msg.Event.(type) {
case models.SessionCreatedEvent:
m.chatSessionID = &e.ChatSessionID
m.needsRename = true
m.status.setSession(e.ChatSessionID)
case models.MessageIDEvent:
m.parentMessageID = &e.ReservedAgentMessageID
case models.MessageStartEvent:
m.agentStarted = true
case models.MessageDeltaEvent:
m.agentStarted = true
m.viewport.appendToken(e.Content)
case models.SearchStartEvent:
if e.IsInternetSearch {
m.viewport.addInfo("Web search…")
} else {
m.viewport.addInfo("Searching…")
}
case models.SearchQueriesEvent:
if len(e.Queries) > 0 {
queries := e.Queries
if len(queries) > 3 {
queries = queries[:3]
}
parts := make([]string, len(queries))
for i, q := range queries {
parts[i] = "\"" + q + "\""
}
m.viewport.addInfo("Searching: " + strings.Join(parts, ", "))
}
case models.SearchDocumentsEvent:
count := len(e.Documents)
suffix := "s"
if count == 1 {
suffix = ""
}
m.viewport.addInfo("Found " + strconv.Itoa(count) + " document" + suffix)
case models.ReasoningStartEvent:
m.viewport.addInfo("Thinking…")
case models.ReasoningDeltaEvent:
// We don't display reasoning text, just the indicator
case models.ReasoningDoneEvent:
// No-op
case models.CitationEvent:
m.citations[e.CitationNumber] = e.DocumentID
case models.ToolStartEvent:
m.viewport.addInfo("Using " + e.ToolName + "…")
case models.ResearchAgentStartEvent:
m.viewport.addInfo("Researching: " + e.ResearchTask)
case models.DeepResearchPlanDeltaEvent:
m.viewport.appendToken(e.Content)
case models.IntermediateReportDeltaEvent:
m.viewport.appendToken(e.Content)
case models.StopEvent:
return m.finishStream(nil)
case models.ErrorEvent:
m.viewport.addError(e.Error)
return m.finishStream(nil)
}
return m, api.WaitForStreamEvent(m.streamCh)
}
func (m Model) handleStreamDone(msg api.StreamDoneMsg) (tea.Model, tea.Cmd) {
// Ignore if already cancelled
if !m.isStreaming {
return m, nil
}
return m.finishStream(msg.Err)
}
func (m Model) finishStream(err error) (Model, tea.Cmd) {
m.viewport.finishAgent()
if m.agentStarted && len(m.citations) > 0 {
m.viewport.addCitations(m.citations)
}
m.isStreaming = false
m.agentStarted = false
m.status.setStreaming(false)
if m.streamCancel != nil {
m.streamCancel()
}
m.streamCancel = nil
m.streamCh = nil
// Auto-rename new sessions
if m.needsRename && m.chatSessionID != nil {
m.needsRename = false
sessionID := *m.chatSessionID
client := m.client
go func() {
_, _ = client.RenameChatSession(sessionID, nil)
}()
}
return m, nil
}
func (m Model) handleInitDone(msg InitDoneMsg) (tea.Model, tea.Cmd) {
if msg.Err != nil {
m.viewport.addWarning("Could not load agents. Using default.")
} else {
m.agents = msg.Agents
for _, p := range m.agents {
if p.ID == m.agentID {
m.agentName = p.Name
break
}
}
}
m.status.setServer(m.config.ServerURL)
m.status.setAgent(m.agentName)
return m, nil
}
func (m Model) handleAgentsLoaded(msg AgentsLoadedMsg) (tea.Model, tea.Cmd) {
if msg.Err != nil {
m.viewport.addError("Could not load agents: " + msg.Err.Error())
return m, nil
}
m.agents = msg.Agents
if len(m.agents) == 0 {
m.viewport.addInfo("No agents available.")
return m, nil
}
m.viewport.addInfo("Select an agent (Enter to select, Esc to cancel):")
var items []pickerItem
for _, p := range m.agents {
label := fmt.Sprintf("%d: %s", p.ID, p.Name)
if p.ID == m.agentID {
label += " *"
}
desc := p.Description
if len(desc) > 50 {
desc = desc[:50] + "..."
}
if desc != "" {
label += " - " + desc
}
items = append(items, pickerItem{
id: strconv.Itoa(p.ID),
label: label,
})
}
m.viewport.showPicker(pickerAgent, items)
return m, nil
}
func (m Model) handleSessionsLoaded(msg SessionsLoadedMsg) (tea.Model, tea.Cmd) {
if msg.Err != nil {
m.viewport.addError("Could not load sessions: " + msg.Err.Error())
return m, nil
}
if len(msg.Sessions) == 0 {
m.viewport.addInfo("No previous sessions found.")
return m, nil
}
m.viewport.addInfo("Select a session to resume (Enter to select, Esc to cancel):")
var items []pickerItem
for i, s := range msg.Sessions {
if i >= 15 {
break
}
name := "Untitled"
if s.Name != nil && *s.Name != "" {
name = *s.Name
}
sid := s.ID
if len(sid) > 8 {
sid = sid[:8]
}
items = append(items, pickerItem{
id: s.ID,
label: sid + " " + name + " (" + s.Created + ")",
})
}
m.viewport.showPicker(pickerSession, items)
return m, nil
}
func (m Model) handleSessionResumed(msg SessionResumedMsg) (tea.Model, tea.Cmd) {
if msg.Err != nil {
m.viewport.addError("Could not load session: " + msg.Err.Error())
return m, nil
}
// Cancel any in-progress stream before replacing the session
if m.isStreaming {
m, _ = m.cancelStream()
}
detail := msg.Detail
m.chatSessionID = &detail.ChatSessionID
m.viewport.clearDisplay()
m.status.setSession(detail.ChatSessionID)
if detail.AgentName != nil {
m.agentName = *detail.AgentName
m.status.setAgent(*detail.AgentName)
}
if detail.AgentID != nil {
m.agentID = *detail.AgentID
}
// Replay messages
for _, chatMsg := range detail.Messages {
switch chatMsg.MessageType {
case "user":
m.viewport.addUserMessage(chatMsg.Message)
case "assistant":
m.viewport.startAgent()
m.viewport.appendToken(chatMsg.Message)
m.viewport.finishAgent()
}
}
// Set parent to last message
if len(detail.Messages) > 0 {
lastID := detail.Messages[len(detail.Messages)-1].MessageID
m.parentMessageID = &lastID
}
desc := "Untitled"
if detail.Description != nil && *detail.Description != "" {
desc = *detail.Description
}
m.viewport.addInfo("Resumed session: " + desc)
return m, nil
}
func (m Model) handleFileUploaded(msg FileUploadedMsg) (tea.Model, tea.Cmd) {
if msg.Err != nil {
m.viewport.addError("Upload failed: " + msg.Err.Error())
return m, nil
}
m.attachedFiles = append(m.attachedFiles, *msg.Descriptor)
m.input.addFile(msg.FileName)
m.viewport.addInfo("Attached: " + msg.FileName)
return m, nil
}
type inputReadyMsg struct{}
type resetQuitMsg struct{}

View File

@@ -0,0 +1,205 @@
package tui
import (
"fmt"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea"
"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/models"
"github.com/onyx-dot-app/onyx/cli/internal/util"
)
// handleSlashCommand dispatches slash commands and returns updated model + cmd.
func handleSlashCommand(m Model, text string) (Model, tea.Cmd) {
parts := strings.SplitN(text, " ", 2)
command := strings.ToLower(parts[0])
arg := ""
if len(parts) > 1 {
arg = parts[1]
}
switch command {
case "/help":
m.viewport.addInfo(helpText)
return m, nil
case "/new":
return cmdNew(m)
case "/agent":
if arg != "" {
return cmdSelectAgent(m, arg)
}
return cmdShowAgents(m)
case "/attach":
return cmdAttach(m, arg)
case "/sessions", "/resume":
if strings.TrimSpace(arg) != "" {
return cmdResume(m, arg)
}
return cmdSessions(m)
case "/configure":
m.viewport.addInfo("Run 'onyx-cli configure' to change connection settings.")
return m, nil
case "/clear":
return cmdNew(m)
case "/connectors":
url := m.config.ServerURL + "/admin/indexing/status"
if util.OpenBrowser(url) {
m.viewport.addInfo("Opened " + url + " in browser")
} else {
m.viewport.addWarning("Failed to open browser. Visit: " + url)
}
return m, nil
case "/settings":
url := m.config.ServerURL + "/app/settings/general"
if util.OpenBrowser(url) {
m.viewport.addInfo("Opened " + url + " in browser")
} else {
m.viewport.addWarning("Failed to open browser. Visit: " + url)
}
return m, nil
case "/quit":
return m, tea.Quit
default:
m.viewport.addWarning(fmt.Sprintf("Unknown command: %s. Type /help for available commands.", command))
return m, nil
}
}
func cmdNew(m Model) (Model, tea.Cmd) {
if m.isStreaming {
m, _ = m.cancelStream()
}
m.chatSessionID = nil
parentID := -1
m.parentMessageID = &parentID
m.needsRename = false
m.citations = nil
m.viewport.clearAll()
// Re-add splash as a scrollable entry
viewportHeight := m.viewportHeight()
if viewportHeight < 1 {
viewportHeight = m.height
}
m.viewport.addSplash(viewportHeight)
m.status.setSession("")
return m, nil
}
func cmdShowAgents(m Model) (Model, tea.Cmd) {
m.viewport.addInfo("Loading agents...")
client := m.client
return m, func() tea.Msg {
agents, err := client.ListAgents()
return AgentsLoadedMsg{Agents: agents, Err: err}
}
}
func cmdSelectAgent(m Model, idStr string) (Model, tea.Cmd) {
pid, err := strconv.Atoi(strings.TrimSpace(idStr))
if err != nil {
m.viewport.addWarning("Invalid agent ID. Use a number.")
return m, nil
}
var target *models.AgentSummary
for i := range m.agents {
if m.agents[i].ID == pid {
target = &m.agents[i]
break
}
}
if target == nil {
m.viewport.addWarning(fmt.Sprintf("Agent %d not found. Use /agent to see available agents.", pid))
return m, nil
}
m.agentID = target.ID
m.agentName = target.Name
m.status.setAgent(target.Name)
m.viewport.addInfo("Switched to agent: " + target.Name)
// Save preference
m.config.DefaultAgentID = target.ID
_ = config.Save(m.config)
return m, nil
}
func cmdAttach(m Model, pathStr string) (Model, tea.Cmd) {
if pathStr == "" {
m.viewport.addWarning("Usage: /attach <file_path>")
return m, nil
}
m.viewport.addInfo("Uploading " + pathStr + "...")
client := m.client
return m, func() tea.Msg {
fd, err := client.UploadFile(pathStr)
if err != nil {
return FileUploadedMsg{Err: err, FileName: pathStr}
}
return FileUploadedMsg{Descriptor: fd, FileName: pathStr}
}
}
func cmdSessions(m Model) (Model, tea.Cmd) {
m.viewport.addInfo("Loading sessions...")
client := m.client
return m, func() tea.Msg {
sessions, err := client.ListChatSessions()
return SessionsLoadedMsg{Sessions: sessions, Err: err}
}
}
func cmdResume(m Model, sessionIDStr string) (Model, tea.Cmd) {
client := m.client
return m, func() tea.Msg {
// Try to find session by prefix match
sessions, err := client.ListChatSessions()
if err != nil {
return SessionResumedMsg{Err: err}
}
var targetID string
for _, s := range sessions {
if strings.HasPrefix(s.ID, sessionIDStr) {
targetID = s.ID
break
}
}
if targetID == "" {
// Try as full UUID
targetID = sessionIDStr
}
detail, err := client.GetChatSession(targetID)
if err != nil {
return SessionResumedMsg{Err: fmt.Errorf("session not found: %s", sessionIDStr)}
}
return SessionResumedMsg{Detail: detail}
}
}
// loadAgentsCmd returns a tea.Cmd that loads agents from the API.
func loadAgentsCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
agents, err := client.ListAgents()
return InitDoneMsg{Agents: agents, Err: err}
}
}

24
cli/internal/tui/help.go Normal file
View File

@@ -0,0 +1,24 @@
package tui
const helpText = `Onyx CLI Commands
/help Show this help message
/new Start a new chat session
/agent List and switch agents
/attach <path> Attach a file to next message
/sessions Browse and resume previous sessions
/clear Clear the chat display
/configure Re-run connection setup
/connectors Open connectors page in browser
/settings Open Onyx settings in browser
/quit Exit Onyx CLI
Keyboard Shortcuts
Enter Send message
Escape Cancel current generation
Ctrl+O Toggle source citations
Ctrl+D Quit (press twice)
Scroll Up/Down Mouse wheel or Shift+Up/Down
Page Up/Down Scroll half page
`

242
cli/internal/tui/input.go Normal file
View File

@@ -0,0 +1,242 @@
package tui
import (
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// slashCommand defines a slash command with its description.
type slashCommand struct {
command string
description string
}
var slashCommands = []slashCommand{
{"/help", "Show help message"},
{"/new", "Start a new chat session"},
{"/agent", "List and switch agents"},
{"/attach", "Attach a file to next message"},
{"/sessions", "Browse and resume previous sessions"},
{"/clear", "Clear the chat display"},
{"/configure", "Re-run connection setup"},
{"/connectors", "Open connectors in browser"},
{"/settings", "Open settings in browser"},
{"/quit", "Exit Onyx CLI"},
}
// Commands that take arguments (filled in with trailing space on Tab/Enter).
var argCommands = map[string]bool{
"/attach": true,
}
// inputModel manages the text input and slash command menu.
type inputModel struct {
textInput textinput.Model
menuVisible bool
menuItems []slashCommand
menuIndex int
attachedFiles []string
}
func newInputModel() inputModel {
ti := textinput.New()
ti.Prompt = "" // We render our own prompt in viewInput()
ti.Placeholder = "Send a message…"
ti.CharLimit = 10000
// Don't focus here — focus after first WindowSizeMsg to avoid
// capturing terminal init escape sequences as input.
return inputModel{
textInput: ti,
}
}
func (m inputModel) update(msg tea.Msg) (inputModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
m = m.updateMenu()
return m, cmd
}
func (m inputModel) handleKey(msg tea.KeyMsg) (inputModel, tea.Cmd) {
switch msg.Type {
case tea.KeyUp:
if m.menuVisible && m.menuIndex > 0 {
m.menuIndex--
return m, nil
}
case tea.KeyDown:
if m.menuVisible && m.menuIndex < len(m.menuItems)-1 {
m.menuIndex++
return m, nil
}
case tea.KeyTab:
if m.menuVisible && len(m.menuItems) > 0 {
cmd := m.menuItems[m.menuIndex].command
if argCommands[cmd] {
m.textInput.SetValue(cmd + " ")
m.textInput.SetCursor(len(cmd) + 1)
} else {
m.textInput.SetValue(cmd)
m.textInput.SetCursor(len(cmd))
}
m.menuVisible = false
return m, nil
}
case tea.KeyEnter:
if m.menuVisible && len(m.menuItems) > 0 {
cmd := m.menuItems[m.menuIndex].command
if argCommands[cmd] {
m.textInput.SetValue(cmd + " ")
m.textInput.SetCursor(len(cmd) + 1)
m.menuVisible = false
return m, nil
}
// Execute immediately
m.textInput.SetValue("")
m.menuVisible = false
return m, func() tea.Msg { return submitMsg{text: cmd} }
}
text := strings.TrimSpace(m.textInput.Value())
if text == "" {
return m, nil
}
// Check for file path (drag-and-drop)
if dropped := detectFileDrop(text); dropped != "" {
m.textInput.SetValue("")
return m, func() tea.Msg { return fileDropMsg{path: dropped} }
}
m.textInput.SetValue("")
m.menuVisible = false
return m, func() tea.Msg { return submitMsg{text: text} }
case tea.KeyEscape:
if m.menuVisible {
m.menuVisible = false
return m, nil
}
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
m = m.updateMenu()
return m, cmd
}
func (m inputModel) updateMenu() inputModel {
val := strings.TrimSpace(m.textInput.Value())
if strings.HasPrefix(val, "/") && !strings.Contains(val, " ") {
needle := strings.ToLower(val)
var filtered []slashCommand
for _, sc := range slashCommands {
if strings.HasPrefix(sc.command, needle) {
filtered = append(filtered, sc)
}
}
if len(filtered) > 0 {
m.menuVisible = true
m.menuItems = filtered
if m.menuIndex >= len(filtered) {
m.menuIndex = 0
}
} else {
m.menuVisible = false
}
} else {
m.menuVisible = false
}
return m
}
func (m *inputModel) addFile(name string) {
m.attachedFiles = append(m.attachedFiles, name)
}
func (m *inputModel) clearFiles() {
m.attachedFiles = nil
}
// submitMsg is sent when user submits text.
type submitMsg struct {
text string
}
// fileDropMsg is sent when a file path is detected.
type fileDropMsg struct {
path string
}
// detectFileDrop checks if the text looks like a file path.
func detectFileDrop(text string) string {
cleaned := strings.Trim(text, "'\"")
if cleaned == "" {
return ""
}
// Only treat as a file drop if it looks explicitly path-like
if !strings.HasPrefix(cleaned, "/") && !strings.HasPrefix(cleaned, "~") &&
!strings.HasPrefix(cleaned, "./") && !strings.HasPrefix(cleaned, "../") {
return ""
}
// Expand ~ to home dir
if strings.HasPrefix(cleaned, "~") {
home, err := os.UserHomeDir()
if err == nil {
cleaned = filepath.Join(home, cleaned[1:])
}
}
abs, err := filepath.Abs(cleaned)
if err != nil {
return ""
}
info, err := os.Stat(abs)
if err != nil {
return ""
}
if info.IsDir() {
return ""
}
return abs
}
// viewMenu renders the slash command menu.
func (m inputModel) viewMenu(width int) string {
if !m.menuVisible || len(m.menuItems) == 0 {
return ""
}
var lines []string
for i, item := range m.menuItems {
prefix := " "
if i == m.menuIndex {
prefix = "> "
}
line := prefix + item.command + " " + statusMsgStyle.Render(item.description)
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
// viewInput renders the input line with prompt and optional file badges.
func (m inputModel) viewInput() string {
var parts []string
if len(m.attachedFiles) > 0 {
badges := strings.Join(m.attachedFiles, "] [")
parts = append(parts, statusMsgStyle.Render("Attached: ["+badges+"]"))
}
parts = append(parts, inputPrompt+m.textInput.View())
return strings.Join(parts, "\n")
}

View File

@@ -0,0 +1,36 @@
package tui
import (
"github.com/onyx-dot-app/onyx/cli/internal/models"
)
// InitDoneMsg signals that async initialization is complete.
type InitDoneMsg struct {
Agents []models.AgentSummary
Err error
}
// SessionsLoadedMsg carries loaded chat sessions.
type SessionsLoadedMsg struct {
Sessions []models.ChatSessionDetails
Err error
}
// SessionResumedMsg carries a loaded session detail.
type SessionResumedMsg struct {
Detail *models.ChatSessionDetailResponse
Err error
}
// FileUploadedMsg carries an uploaded file descriptor.
type FileUploadedMsg struct {
Descriptor *models.FileDescriptorPayload
FileName string
Err error
}
// AgentsLoadedMsg carries freshly fetched agents from the API.
type AgentsLoadedMsg struct {
Agents []models.AgentSummary
Err error
}

View File

@@ -0,0 +1,79 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
const onyxLogo = ` ██████╗ ███╗ ██╗██╗ ██╗██╗ ██╗
██╔═══██╗████╗ ██║╚██╗ ██╔╝╚██╗██╔╝
██║ ██║██╔██╗ ██║ ╚████╔╝ ╚███╔╝
██║ ██║██║╚██╗██║ ╚██╔╝ ██╔██╗
╚██████╔╝██║ ╚████║ ██║ ██╔╝ ██╗
╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝`
const tagline = "Your terminal interface for Onyx"
const splashHint = "Type a message to begin · /help for commands"
// renderSplash renders the splash screen centered for the given dimensions.
func renderSplash(width, height int) string {
// Render the logo as a single block (don't center individual lines)
logo := splashStyle.Render(onyxLogo)
// Center tagline and hint relative to the logo block width
logoWidth := lipgloss.Width(logo)
tag := lipgloss.NewStyle().Width(logoWidth).Align(lipgloss.Center).Render(
taglineStyle.Render(tagline),
)
hint := lipgloss.NewStyle().Width(logoWidth).Align(lipgloss.Center).Render(
hintStyle.Render(splashHint),
)
block := lipgloss.JoinVertical(lipgloss.Left, logo, "", tag, hint)
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, block)
}
// RenderSplashOnboarding renders splash for the terminal onboarding screen.
func RenderSplashOnboarding(width, height int) string {
// Render the logo as a styled block, then center it as a unit
styledLogo := splashStyle.Render(onyxLogo)
logoWidth := lipgloss.Width(styledLogo)
logoLines := strings.Split(styledLogo, "\n")
logoHeight := len(logoLines)
contentHeight := logoHeight + 2 // logo + blank + tagline
topPad := (height - contentHeight) / 2
if topPad < 1 {
topPad = 1
}
// Center the entire logo block horizontally
blockPad := (width - logoWidth) / 2
if blockPad < 0 {
blockPad = 0
}
var b strings.Builder
for i := 0; i < topPad; i++ {
b.WriteByte('\n')
}
for _, line := range logoLines {
b.WriteString(strings.Repeat(" ", blockPad))
b.WriteString(line)
b.WriteByte('\n')
}
b.WriteByte('\n')
tagPad := (width - len(tagline)) / 2
if tagPad < 0 {
tagPad = 0
}
b.WriteString(strings.Repeat(" ", tagPad))
b.WriteString(taglineStyle.Render(tagline))
b.WriteByte('\n')
return b.String()
}

View File

@@ -0,0 +1,60 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// statusBar manages the footer status display.
type statusBar struct {
agentName string
serverURL string
sessionID string
streaming bool
width int
}
func newStatusBar() statusBar {
return statusBar{
agentName: "Default",
}
}
func (s *statusBar) setAgent(name string) { s.agentName = name }
func (s *statusBar) setServer(url string) { s.serverURL = url }
func (s *statusBar) setSession(id string) {
if len(id) > 8 {
id = id[:8]
}
s.sessionID = id
}
func (s *statusBar) setStreaming(v bool) { s.streaming = v }
func (s *statusBar) setWidth(w int) { s.width = w }
func (s statusBar) view() string {
var leftParts []string
if s.serverURL != "" {
leftParts = append(leftParts, s.serverURL)
}
name := s.agentName
if name == "" {
name = "Default"
}
leftParts = append(leftParts, name)
left := statusBarStyle.Render(strings.Join(leftParts, " · "))
right := "Ctrl+D to quit"
if s.streaming {
right = "Esc to cancel"
}
rightRendered := statusBarStyle.Render(right)
// Fill space between left and right
gap := s.width - lipgloss.Width(left) - lipgloss.Width(rightRendered)
if gap < 1 {
gap = 1
}
return left + strings.Repeat(" ", gap) + rightRendered
}

View File

@@ -0,0 +1,29 @@
package tui
import "github.com/charmbracelet/lipgloss"
var (
// Colors
accentColor = lipgloss.Color("#6c8ebf")
dimColor = lipgloss.Color("#555577")
errorColor = lipgloss.Color("#ff5555")
splashColor = lipgloss.Color("#7C6AEF")
separatorColor = lipgloss.Color("#333355")
citationColor = lipgloss.Color("#666688")
// Styles
userPrefixStyle = lipgloss.NewStyle().Foreground(dimColor)
agentDot = lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("◉")
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#b0b0cc"))
dimInfoStyle = lipgloss.NewStyle().Foreground(dimColor)
statusMsgStyle = dimInfoStyle // used for slash menu descriptions, file badges
errorStyle = lipgloss.NewStyle().Foreground(errorColor).Bold(true)
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffcc00"))
citationStyle = lipgloss.NewStyle().Foreground(citationColor)
statusBarStyle = lipgloss.NewStyle().Foreground(dimColor)
inputPrompt = lipgloss.NewStyle().Foreground(accentColor).Render(" ")
splashStyle = lipgloss.NewStyle().Foreground(splashColor).Bold(true)
taglineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A0A0A0"))
hintStyle = lipgloss.NewStyle().Foreground(dimColor)
)

View File

@@ -0,0 +1,447 @@
package tui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
)
// entryKind is the type of chat entry.
type entryKind int
const (
entryUser entryKind = iota
entryAgent
entryInfo
entryError
entryCitation
)
// chatEntry is a single rendered entry in the chat history.
type chatEntry struct {
kind entryKind
content string // raw content (for agent: the markdown source)
rendered string // pre-rendered output
citations []string // citation lines (for citation entries)
}
// pickerKind distinguishes what the picker is selecting.
type pickerKind int
const (
pickerSession pickerKind = iota
pickerAgent
)
// pickerItem is a selectable item in the picker.
type pickerItem struct {
id string
label string
}
// viewport manages the chat display.
type viewport struct {
entries []chatEntry
width int
streaming bool
streamBuf string
showSources bool
renderer *glamour.TermRenderer
pickerItems []pickerItem
pickerActive bool
pickerIndex int
pickerType pickerKind
scrollOffset int // lines scrolled up from bottom (0 = pinned to bottom)
lastHeight int // viewport height from last render
}
// newMarkdownRenderer creates a Glamour renderer with zero left margin.
func newMarkdownRenderer(width int) *glamour.TermRenderer {
style := styles.DarkStyleConfig
zero := uint(0)
style.Document.Margin = &zero
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(style),
glamour.WithWordWrap(width-4),
)
return r
}
func newViewport(width int) *viewport {
return &viewport{
width: width,
renderer: newMarkdownRenderer(width),
}
}
func (v *viewport) addSplash(height int) {
splash := renderSplash(v.width, height)
v.entries = append(v.entries, chatEntry{
kind: entryInfo,
rendered: splash,
})
}
func (v *viewport) setWidth(w int) {
v.width = w
v.renderer = newMarkdownRenderer(w)
}
func (v *viewport) addUserMessage(msg string) {
rendered := "\n" + userPrefixStyle.Render(" ") + msg
v.entries = append(v.entries, chatEntry{
kind: entryUser,
content: msg,
rendered: rendered,
})
}
func (v *viewport) startAgent() {
v.streaming = true
v.streamBuf = ""
// Add a blank-line spacer entry before the agent message
v.entries = append(v.entries, chatEntry{kind: entryInfo, rendered: ""})
}
func (v *viewport) appendToken(token string) {
v.streamBuf += token
}
func (v *viewport) finishAgent() {
if v.streamBuf == "" {
v.streaming = false
// Remove the blank spacer entry added by startAgent()
if len(v.entries) > 0 && v.entries[len(v.entries)-1].kind == entryInfo && v.entries[len(v.entries)-1].rendered == "" {
v.entries = v.entries[:len(v.entries)-1]
}
return
}
// Render markdown with Glamour (zero left margin style)
rendered := v.renderMarkdown(v.streamBuf)
rendered = strings.TrimLeft(rendered, "\n")
rendered = strings.TrimRight(rendered, "\n")
lines := strings.Split(rendered, "\n")
// Prefix first line with dot, indent continuation lines
if len(lines) > 0 {
lines[0] = agentDot + " " + lines[0]
for i := 1; i < len(lines); i++ {
lines[i] = " " + lines[i]
}
}
rendered = strings.Join(lines, "\n")
v.entries = append(v.entries, chatEntry{
kind: entryAgent,
content: v.streamBuf,
rendered: rendered,
})
v.streaming = false
v.streamBuf = ""
}
func (v *viewport) renderMarkdown(md string) string {
if v.renderer == nil {
return md
}
out, err := v.renderer.Render(md)
if err != nil {
return md
}
return out
}
func (v *viewport) addInfo(msg string) {
rendered := infoStyle.Render("● " + msg)
v.entries = append(v.entries, chatEntry{
kind: entryInfo,
content: msg,
rendered: rendered,
})
}
func (v *viewport) addWarning(msg string) {
rendered := warnStyle.Render("● " + msg)
v.entries = append(v.entries, chatEntry{
kind: entryError,
content: msg,
rendered: rendered,
})
}
func (v *viewport) addError(msg string) {
rendered := errorStyle.Render("● Error: ") + msg
v.entries = append(v.entries, chatEntry{
kind: entryError,
content: msg,
rendered: rendered,
})
}
func (v *viewport) addCitations(citations map[int]string) {
if len(citations) == 0 {
return
}
keys := make([]int, 0, len(citations))
for k := range citations {
keys = append(keys, k)
}
sort.Ints(keys)
var parts []string
for _, num := range keys {
parts = append(parts, fmt.Sprintf("[%d] %s", num, citations[num]))
}
text := fmt.Sprintf("Sources (%d): %s", len(citations), strings.Join(parts, " "))
var citLines []string
citLines = append(citLines, text)
v.entries = append(v.entries, chatEntry{
kind: entryCitation,
content: text,
rendered: citationStyle.Render("● "+text),
citations: citLines,
})
}
func (v *viewport) showPicker(kind pickerKind, items []pickerItem) {
v.pickerItems = items
v.pickerType = kind
v.pickerActive = true
v.pickerIndex = 0
}
func (v *viewport) maxScroll() int {
ms := v.totalLines() - v.lastHeight
if ms < 0 {
return 0
}
return ms
}
func (v *viewport) scrollUp(n int) {
v.scrollOffset += n
if ms := v.maxScroll(); v.scrollOffset > ms {
v.scrollOffset = ms
}
}
func (v *viewport) scrollDown(n int) {
v.scrollOffset -= n
if v.scrollOffset < 0 {
v.scrollOffset = 0
}
}
func (v *viewport) clearAll() {
v.entries = nil
v.streaming = false
v.streamBuf = ""
v.pickerItems = nil
v.pickerActive = false
v.scrollOffset = 0
}
func (v *viewport) clearDisplay() {
v.entries = nil
v.scrollOffset = 0
v.streaming = false
v.streamBuf = ""
}
// pickerTitle returns a title for the current picker kind.
func (v *viewport) pickerTitle() string {
switch v.pickerType {
case pickerAgent:
return "Select Agent"
case pickerSession:
return "Resume Session"
default:
return "Select"
}
}
// renderPicker renders the picker as a bordered overlay.
func (v *viewport) renderPicker(width, height int) string {
title := v.pickerTitle()
// Determine picker dimensions
maxItems := len(v.pickerItems)
panelWidth := width - 4
if panelWidth < 30 {
panelWidth = 30
}
if panelWidth > 70 {
panelWidth = 70
}
innerWidth := panelWidth - 4 // border + padding
// Visible window of items (scroll if too many)
maxVisible := height - 6 // room for border, title, hint
if maxVisible < 3 {
maxVisible = 3
}
if maxVisible > maxItems {
maxVisible = maxItems
}
// Calculate scroll window around current index
startIdx := 0
if v.pickerIndex >= maxVisible {
startIdx = v.pickerIndex - maxVisible + 1
}
endIdx := startIdx + maxVisible
if endIdx > maxItems {
endIdx = maxItems
startIdx = endIdx - maxVisible
if startIdx < 0 {
startIdx = 0
}
}
var itemLines []string
for i := startIdx; i < endIdx; i++ {
item := v.pickerItems[i]
label := item.label
labelRunes := []rune(label)
if len(labelRunes) > innerWidth-4 {
label = string(labelRunes[:innerWidth-7]) + "..."
}
if i == v.pickerIndex {
line := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("> " + label)
itemLines = append(itemLines, line)
} else {
itemLines = append(itemLines, " "+label)
}
}
hint := lipgloss.NewStyle().Foreground(dimColor).Render("↑↓ navigate • enter select • esc cancel")
body := strings.Join(itemLines, "\n") + "\n\n" + hint
panel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accentColor).
Padding(1, 2).
Width(panelWidth).
Render(body)
titleRendered := lipgloss.NewStyle().
Foreground(accentColor).
Bold(true).
Render(" " + title + " ")
// Build top border manually to avoid ANSI-corrupted rune slicing.
// panelWidth+2 accounts for the left and right border characters.
borderColor := lipgloss.NewStyle().Foreground(accentColor)
titleWidth := lipgloss.Width(titleRendered)
rightDashes := panelWidth + 2 - 3 - titleWidth // total - "╭─" - "╮" - title
if rightDashes < 0 {
rightDashes = 0
}
topBorder := borderColor.Render("╭─") + titleRendered +
borderColor.Render(strings.Repeat("─", rightDashes)+"╮")
panelLines := strings.Split(panel, "\n")
if len(panelLines) > 0 {
panelLines[0] = topBorder
}
panel = strings.Join(panelLines, "\n")
// Center the panel in the viewport
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, panel)
}
// totalLines computes the total number of rendered content lines.
func (v *viewport) totalLines() int {
var lines []string
for _, e := range v.entries {
if e.kind == entryCitation && !v.showSources {
continue
}
lines = append(lines, e.rendered)
}
if v.streaming && v.streamBuf != "" {
bufLines := strings.Split(v.streamBuf, "\n")
if len(bufLines) > 0 {
bufLines[0] = agentDot + " " + bufLines[0]
for i := 1; i < len(bufLines); i++ {
bufLines[i] = " " + bufLines[i]
}
}
lines = append(lines, strings.Join(bufLines, "\n"))
} else if v.streaming {
lines = append(lines, agentDot+" ")
}
content := strings.Join(lines, "\n")
return len(strings.Split(content, "\n"))
}
// view renders the full viewport content.
func (v *viewport) view(height int) string {
// If picker is active, render it as an overlay
if v.pickerActive && len(v.pickerItems) > 0 {
return v.renderPicker(v.width, height)
}
var lines []string
for _, e := range v.entries {
if e.kind == entryCitation && !v.showSources {
continue
}
lines = append(lines, e.rendered)
}
// Streaming buffer (plain text, not markdown)
if v.streaming && v.streamBuf != "" {
bufLines := strings.Split(v.streamBuf, "\n")
if len(bufLines) > 0 {
bufLines[0] = agentDot + " " + bufLines[0]
for i := 1; i < len(bufLines); i++ {
bufLines[i] = " " + bufLines[i]
}
}
lines = append(lines, strings.Join(bufLines, "\n"))
} else if v.streaming {
lines = append(lines, agentDot+" ")
}
content := strings.Join(lines, "\n")
contentLines := strings.Split(content, "\n")
total := len(contentLines)
v.lastHeight = height
maxScroll := total - height
if maxScroll < 0 {
maxScroll = 0
}
scrollOffset := v.scrollOffset
if scrollOffset > maxScroll {
scrollOffset = maxScroll
}
if total <= height {
// Content fits — pad with empty lines at top to push content down
padding := make([]string, height-total)
for i := range padding {
padding[i] = ""
}
contentLines = append(padding, contentLines...)
} else {
// Show a window: end is (total - scrollOffset), start is (end - height)
end := total - scrollOffset
start := end - height
if start < 0 {
start = 0
}
contentLines = contentLines[start:end]
}
return strings.Join(contentLines, "\n")
}

View File

@@ -0,0 +1,264 @@
package tui
import (
"regexp"
"strings"
"testing"
)
// stripANSI removes ANSI escape sequences for test comparisons.
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func stripANSI(s string) string {
return ansiRegex.ReplaceAllString(s, "")
}
func TestAddUserMessage(t *testing.T) {
v := newViewport(80)
v.addUserMessage("hello world")
if len(v.entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(v.entries))
}
e := v.entries[0]
if e.kind != entryUser {
t.Errorf("expected entryUser, got %d", e.kind)
}
if e.content != "hello world" {
t.Errorf("expected content 'hello world', got %q", e.content)
}
plain := stripANSI(e.rendered)
if !strings.Contains(plain, "") {
t.Errorf("expected rendered to contain , got %q", plain)
}
if !strings.Contains(plain, "hello world") {
t.Errorf("expected rendered to contain message text, got %q", plain)
}
}
func TestStartAndFinishAgent(t *testing.T) {
v := newViewport(80)
v.startAgent()
if !v.streaming {
t.Error("expected streaming to be true after startAgent")
}
if len(v.entries) != 1 {
t.Fatalf("expected 1 spacer entry, got %d", len(v.entries))
}
if v.entries[0].rendered != "" {
t.Errorf("expected empty spacer, got %q", v.entries[0].rendered)
}
v.appendToken("Hello ")
v.appendToken("world")
if v.streamBuf != "Hello world" {
t.Errorf("expected streamBuf 'Hello world', got %q", v.streamBuf)
}
v.finishAgent()
if v.streaming {
t.Error("expected streaming to be false after finishAgent")
}
if v.streamBuf != "" {
t.Errorf("expected empty streamBuf after finish, got %q", v.streamBuf)
}
if len(v.entries) != 2 {
t.Fatalf("expected 2 entries (spacer + agent), got %d", len(v.entries))
}
e := v.entries[1]
if e.kind != entryAgent {
t.Errorf("expected entryAgent, got %d", e.kind)
}
if e.content != "Hello world" {
t.Errorf("expected content 'Hello world', got %q", e.content)
}
plain := stripANSI(e.rendered)
if !strings.Contains(plain, "Hello world") {
t.Errorf("expected rendered to contain message text, got %q", plain)
}
}
func TestFinishAgentNoPadding(t *testing.T) {
v := newViewport(80)
v.startAgent()
v.appendToken("Test message")
v.finishAgent()
e := v.entries[1]
// First line should not start with plain spaces (ANSI codes are OK)
plain := stripANSI(e.rendered)
lines := strings.Split(plain, "\n")
if strings.HasPrefix(lines[0], " ") {
t.Errorf("first line should not start with spaces, got %q", lines[0])
}
}
func TestFinishAgentMultiline(t *testing.T) {
v := newViewport(80)
v.startAgent()
v.appendToken("Line one\n\nLine three")
v.finishAgent()
e := v.entries[1]
plain := stripANSI(e.rendered)
// Glamour may merge or reformat lines; just check content is present
if !strings.Contains(plain, "Line one") {
t.Errorf("expected 'Line one' in rendered, got %q", plain)
}
if !strings.Contains(plain, "Line three") {
t.Errorf("expected 'Line three' in rendered, got %q", plain)
}
}
func TestFinishAgentEmpty(t *testing.T) {
v := newViewport(80)
v.startAgent()
v.finishAgent()
if v.streaming {
t.Error("expected streaming to be false")
}
if len(v.entries) != 0 {
t.Errorf("expected 0 entries (spacer removed), got %d", len(v.entries))
}
}
func TestAddInfo(t *testing.T) {
v := newViewport(80)
v.addInfo("test info")
if len(v.entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(v.entries))
}
e := v.entries[0]
if e.kind != entryInfo {
t.Errorf("expected entryInfo, got %d", e.kind)
}
plain := stripANSI(e.rendered)
if strings.HasPrefix(plain, " ") {
t.Errorf("info should not have leading spaces, got %q", plain)
}
}
func TestAddError(t *testing.T) {
v := newViewport(80)
v.addError("something broke")
if len(v.entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(v.entries))
}
e := v.entries[0]
if e.kind != entryError {
t.Errorf("expected entryError, got %d", e.kind)
}
plain := stripANSI(e.rendered)
if !strings.Contains(plain, "something broke") {
t.Errorf("expected error message in rendered, got %q", plain)
}
}
func TestAddCitations(t *testing.T) {
v := newViewport(80)
v.addCitations(map[int]string{1: "doc-a", 2: "doc-b"})
if len(v.entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(v.entries))
}
e := v.entries[0]
if e.kind != entryCitation {
t.Errorf("expected entryCitation, got %d", e.kind)
}
plain := stripANSI(e.rendered)
if !strings.Contains(plain, "Sources (2)") {
t.Errorf("expected sources count in rendered, got %q", plain)
}
if strings.HasPrefix(plain, " ") {
t.Errorf("citation should not have leading spaces, got %q", plain)
}
}
func TestAddCitationsEmpty(t *testing.T) {
v := newViewport(80)
v.addCitations(map[int]string{})
if len(v.entries) != 0 {
t.Errorf("expected no entries for empty citations, got %d", len(v.entries))
}
}
func TestCitationVisibility(t *testing.T) {
v := newViewport(80)
v.addInfo("hello")
v.addCitations(map[int]string{1: "doc"})
v.showSources = false
view := v.view(20)
plain := stripANSI(view)
if strings.Contains(plain, "Sources") {
t.Error("expected citations hidden when showSources=false")
}
v.showSources = true
view = v.view(20)
plain = stripANSI(view)
if !strings.Contains(plain, "Sources") {
t.Error("expected citations visible when showSources=true")
}
}
func TestClearAll(t *testing.T) {
v := newViewport(80)
v.addUserMessage("test")
v.startAgent()
v.appendToken("response")
v.clearAll()
if len(v.entries) != 0 {
t.Errorf("expected no entries after clearAll, got %d", len(v.entries))
}
if v.streaming {
t.Error("expected streaming=false after clearAll")
}
if v.streamBuf != "" {
t.Errorf("expected empty streamBuf after clearAll, got %q", v.streamBuf)
}
}
func TestClearDisplay(t *testing.T) {
v := newViewport(80)
v.addUserMessage("test")
v.clearDisplay()
if len(v.entries) != 0 {
t.Errorf("expected no entries after clearDisplay, got %d", len(v.entries))
}
}
func TestViewPadsShortContent(t *testing.T) {
v := newViewport(80)
v.addInfo("hello")
view := v.view(10)
lines := strings.Split(view, "\n")
if len(lines) != 10 {
t.Errorf("expected 10 lines (padded), got %d", len(lines))
}
}
func TestViewTruncatesTallContent(t *testing.T) {
v := newViewport(80)
for i := 0; i < 20; i++ {
v.addInfo("line")
}
view := v.view(5)
lines := strings.Split(view, "\n")
if len(lines) != 5 {
t.Errorf("expected 5 lines (truncated), got %d", len(lines))
}
}

View File

@@ -0,0 +1,29 @@
// Package util provides shared utility functions.
package util
import (
"os/exec"
"runtime"
)
// OpenBrowser opens the given URL in the user's default browser.
// Returns true if the browser was launched successfully.
func OpenBrowser(url string) bool {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
}
if cmd != nil {
if err := cmd.Start(); err == nil {
// Reap the child process to avoid zombies.
go func() { _ = cmd.Wait() }()
return true
}
}
return false
}

View File

@@ -0,0 +1,13 @@
// Package util provides shared utilities for the Onyx CLI.
package util
import "github.com/charmbracelet/lipgloss"
// Shared text styles used across the CLI.
var (
BoldStyle = lipgloss.NewStyle().Bold(true)
DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555577"))
GreenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00cc66")).Bold(true)
RedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff5555")).Bold(true)
YellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffcc00"))
)

23
cli/main.go Normal file
View File

@@ -0,0 +1,23 @@
package main
import (
"fmt"
"os"
"github.com/onyx-dot-app/onyx/cli/cmd"
)
var (
version = "dev"
commit = "none"
)
func main() {
cmd.Version = version
cmd.Commit = commit
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

View File

@@ -144,7 +144,7 @@ dev = [
"matplotlib==3.10.8",
"mypy-extensions==1.0.0",
"mypy==1.13.0",
"onyx-devtools==0.6.2",
"onyx-devtools==0.6.3",
"openapi-generator-cli==7.17.0",
"pandas-stubs~=2.3.3",
"pre-commit==3.2.2",

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"regexp"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
@@ -33,11 +34,15 @@ func NewCherryPickCommand() *cobra.Command {
opts := &CherryPickOptions{}
cmd := &cobra.Command{
Use: "cherry-pick <commit-sha> [<commit-sha>...]",
Use: "cherry-pick <commit-or-pr> [<commit-or-pr>...]",
Aliases: []string{"cp"},
Short: "Cherry-pick one or more commits to a release branch",
Short: "Cherry-pick one or more commits (or PRs) to a release branch",
Long: `Cherry-pick one or more commits to a release branch and create a PR.
Arguments can be commit SHAs or GitHub PR numbers. A purely numeric argument
with fewer than 6 digits is treated as a PR number and resolved to its merge
commit automatically.
This command will:
1. Find the nearest stable version tag
2. Fetch the corresponding release branch(es)
@@ -54,7 +59,8 @@ If a cherry-pick hits a merge conflict, resolve it manually, then run:
Example usage:
$ ods cherry-pick foo123 bar456 --release 2.5 --release 2.6
$ ods cp foo123 --release 2.5`,
$ ods cp foo123 --release 2.5
$ ods cp 1234 --release 2.5 # cherry-pick merge commit of PR #1234`,
Args: func(cmd *cobra.Command, args []string) error {
cont, _ := cmd.Flags().GetBool("continue")
if cont {
@@ -90,11 +96,12 @@ Example usage:
func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
git.CheckGitHubCLI()
commitSHAs := args
// Resolve any PR numbers (e.g. "1234") to their merge commit SHAs
commitSHAs, labels := resolveArgs(args)
if len(commitSHAs) == 1 {
log.Debugf("Cherry-picking commit: %s", commitSHAs[0])
log.Debugf("Cherry-picking %s (%s)", labels[0], commitSHAs[0])
} else {
log.Debugf("Cherry-picking %d commits: %s", len(commitSHAs), strings.Join(commitSHAs, ", "))
log.Debugf("Cherry-picking %d commits: %s", len(commitSHAs), strings.Join(labels, ", "))
}
if opts.DryRun {
@@ -294,6 +301,11 @@ func runCherryPickContinue() {
log.Infof("Resuming cherry-pick (original branch: %s, releases: %v)", state.OriginalBranch, state.Releases)
// If a rebase is in progress (REBASE_HEAD exists), it must be resolved first
if git.IsRebaseInProgress() {
log.Fatal("A git rebase is in progress. Resolve it first:\n To continue: git rebase --continue\n To abort: git rebase --abort\nThen re-run: ods cherry-pick --continue")
}
// If git cherry-pick is still in progress (CHERRY_PICK_HEAD exists), continue it
if git.IsCherryPickInProgress() {
log.Info("Continuing in-progress cherry-pick...")
@@ -327,6 +339,23 @@ func cherryPickToRelease(commitSHAs, commitMessages []string, branchSuffix, vers
return "", fmt.Errorf("failed to checkout existing hotfix branch: %w", err)
}
// Only rebase when the branch has no unique commits (pure fast-forward).
// If unique commits exist (e.g. after --continue resolved a cherry-pick
// conflict), rebasing would re-apply them and risk the same conflicts.
remoteRef := fmt.Sprintf("origin/%s", releaseBranch)
uniqueCount, err := git.CountUniqueCommits(hotfixBranch, remoteRef)
if err != nil {
log.Warnf("Could not determine unique commits, skipping rebase: %v", err)
} else if uniqueCount == 0 {
log.Infof("Rebasing %s onto %s", hotfixBranch, releaseBranch)
if err := git.RunCommand("rebase", "--quiet", remoteRef); err != nil {
_ = git.RunCommand("rebase", "--abort")
return "", fmt.Errorf("failed to rebase hotfix branch onto %s (rebase aborted, re-run to retry): %w", releaseBranch, err)
}
} else {
log.Infof("Branch %s has %d unique commit(s), skipping rebase", hotfixBranch, uniqueCount)
}
// Check which commits need to be cherry-picked
commitsToCherry := []string{}
for _, sha := range commitSHAs {
@@ -364,7 +393,6 @@ func cherryPickToRelease(commitSHAs, commitMessages []string, branchSuffix, vers
return "", nil
}
// Push the hotfix branch
log.Infof("Pushing hotfix branch: %s", hotfixBranch)
pushArgs := []string{"push", "-u", "origin", hotfixBranch}
if noVerify {
@@ -432,6 +460,40 @@ func performCherryPick(commitSHAs []string) error {
return nil
}
// isPRNumber returns true if the argument looks like a GitHub PR number
// (purely numeric with fewer than 6 digits).
func isPRNumber(arg string) bool {
if len(arg) == 0 || len(arg) >= 6 {
return false
}
n, err := strconv.Atoi(arg)
return err == nil && n > 0
}
// resolveArgs resolves arguments that may be PR numbers into commit SHAs.
// Returns the resolved commit SHAs and a display-friendly label for logging
// (e.g. "PR #1234" instead of raw SHA).
func resolveArgs(args []string) (commitSHAs []string, labels []string) {
commitSHAs = make([]string, len(args))
labels = make([]string, len(args))
for i, arg := range args {
if isPRNumber(arg) {
log.Infof("Resolving PR #%s to merge commit...", arg)
sha, err := git.ResolvePRToMergeCommit(arg)
if err != nil {
log.Fatalf("Failed to resolve PR #%s: %v", arg, err)
}
log.Infof("PR #%s → %s", arg, sha)
commitSHAs[i] = sha
labels[i] = fmt.Sprintf("PR #%s", arg)
} else {
commitSHAs[i] = arg
labels[i] = arg
}
}
return commitSHAs, labels
}
// normalizeVersion ensures the version has a 'v' prefix
func normalizeVersion(version string) string {
if !strings.HasPrefix(version, "v") {

144
tools/ods/cmd/desktop.go Normal file
View File

@@ -0,0 +1,144 @@
package cmd
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
)
type desktopPackageJSON struct {
Scripts map[string]string `json:"scripts"`
}
// NewDesktopCommand creates a command that runs npm scripts from the desktop directory.
func NewDesktopCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "desktop <script> [args...]",
Short: "Run desktop/package.json npm scripts",
Long: desktopHelpDescription(),
Args: cobra.MinimumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return desktopScriptNames(), cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
runDesktopScript(args)
},
}
cmd.Flags().SetInterspersed(false)
return cmd
}
func runDesktopScript(args []string) {
desktopDir, err := desktopDir()
if err != nil {
log.Fatalf("Failed to find desktop directory: %v", err)
}
scriptName := args[0]
scriptArgs := args[1:]
if len(scriptArgs) > 0 && scriptArgs[0] == "--" {
scriptArgs = scriptArgs[1:]
}
npmArgs := []string{"run", scriptName}
if len(scriptArgs) > 0 {
// npm requires "--" to forward flags to the underlying script.
npmArgs = append(npmArgs, "--")
npmArgs = append(npmArgs, scriptArgs...)
}
log.Debugf("Running in %s: npm %v", desktopDir, npmArgs)
desktopCmd := exec.Command("npm", npmArgs...)
desktopCmd.Dir = desktopDir
desktopCmd.Stdout = os.Stdout
desktopCmd.Stderr = os.Stderr
desktopCmd.Stdin = os.Stdin
if err := desktopCmd.Run(); err != nil {
// For wrapped commands, preserve the child process's exit code and
// avoid duplicating already-printed stderr output.
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if code := exitErr.ExitCode(); code != -1 {
os.Exit(code)
}
}
log.Fatalf("Failed to run npm: %v", err)
}
}
func desktopScriptNames() []string {
scripts, err := loadDesktopScripts()
if err != nil {
return nil
}
names := make([]string, 0, len(scripts))
for name := range scripts {
names = append(names, name)
}
sort.Strings(names)
return names
}
func desktopHelpDescription() string {
description := `Run npm scripts from desktop/package.json.
Examples:
ods desktop dev
ods desktop build
ods desktop build:dmg`
scripts := desktopScriptNames()
if len(scripts) == 0 {
return description + "\n\nAvailable scripts: (unable to load)"
}
return description + "\n\nAvailable scripts:\n " + strings.Join(scripts, "\n ")
}
func loadDesktopScripts() (map[string]string, error) {
desktopDir, err := desktopDir()
if err != nil {
return nil, err
}
packageJSONPath := filepath.Join(desktopDir, "package.json")
data, err := os.ReadFile(packageJSONPath)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", packageJSONPath, err)
}
var pkg desktopPackageJSON
if err := json.Unmarshal(data, &pkg); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", packageJSONPath, err)
}
if pkg.Scripts == nil {
return nil, nil
}
return pkg.Scripts, nil
}
func desktopDir() (string, error) {
root, err := paths.GitRoot()
if err != nil {
return "", err
}
return filepath.Join(root, "desktop"), nil
}

View File

@@ -50,6 +50,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(NewPullCommand())
cmd.AddCommand(NewRunCICommand())
cmd.AddCommand(NewScreenshotDiffCommand())
cmd.AddCommand(NewDesktopCommand())
cmd.AddCommand(NewWebCommand())
cmd.AddCommand(NewWhoisCommand())

View File

@@ -1,14 +1,14 @@
module github.com/onyx-dot-app/onyx/tools/ods
go 1.24.11
go 1.26.0
require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
)

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
@@ -173,6 +174,26 @@ func IsCherryPickInProgress() bool {
return cmd.Run() == nil
}
// CountUniqueCommits returns the number of commits on branch that are not on upstream.
func CountUniqueCommits(branch, upstream string) (int, error) {
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", upstream, branch))
output, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("git rev-list --count failed: %w", err)
}
count, err := strconv.Atoi(strings.TrimSpace(string(output)))
if err != nil {
return 0, fmt.Errorf("failed to parse commit count: %w", err)
}
return count, nil
}
// IsRebaseInProgress checks if a rebase is currently in progress
func IsRebaseInProgress() bool {
cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", "REBASE_HEAD")
return cmd.Run() == nil
}
// HasStagedChanges checks if there are staged changes in the index
func HasStagedChanges() bool {
cmd := exec.Command("git", "diff", "--quiet", "--cached")
@@ -216,6 +237,23 @@ func IsCommitAppliedOnBranch(commitSHA, branchName string) bool {
return false
}
// ResolvePRToMergeCommit resolves a GitHub PR number to its merge commit SHA
func ResolvePRToMergeCommit(prNumber string) (string, error) {
cmd := exec.Command("gh", "pr", "view", prNumber, "--json", "mergeCommit", "--jq", ".mergeCommit.oid")
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("gh pr view failed: %w: %s", err, string(exitErr.Stderr))
}
return "", fmt.Errorf("gh pr view failed: %w", err)
}
sha := strings.TrimSpace(string(output))
if sha == "" || sha == "null" {
return "", fmt.Errorf("PR #%s has no merge commit (is it merged?)", prNumber)
}
return sha, nil
}
// RunCherryPickContinue runs git cherry-pick --continue --no-edit
func RunCherryPickContinue() error {
return RunCommandVerboseOnError("cherry-pick", "--continue", "--no-edit")

View File

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

26
uv.lock generated
View File

@@ -756,12 +756,20 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" },
{ url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" },
{ url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" },
{ url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" },
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
{ url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" },
{ url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
{ url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
@@ -4655,7 +4663,7 @@ requires-dist = [
{ name = "numpy", marker = "extra == 'model-server'", specifier = "==2.4.1" },
{ name = "oauthlib", marker = "extra == 'backend'", specifier = "==3.2.2" },
{ name = "office365-rest-python-client", marker = "extra == 'backend'", specifier = "==2.6.2" },
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.6.2" },
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.6.3" },
{ name = "openai", specifier = "==2.14.0" },
{ name = "openapi-generator-cli", marker = "extra == 'dev'", specifier = "==7.17.0" },
{ name = "openinference-instrumentation", marker = "extra == 'backend'", specifier = "==0.1.42" },
@@ -4760,20 +4768,20 @@ requires-dist = [{ name = "onyx", extras = ["backend", "dev", "ee"], editable =
[[package]]
name = "onyx-devtools"
version = "0.6.2"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "openapi-generator-cli" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/d9f6089616044b0fb6e097cbae82122de24f3acd97820be4868d5c28ee3f/onyx_devtools-0.6.2-py3-none-any.whl", hash = "sha256:e48d14695d39d62ec3247a4c76ea56604bc5fb635af84c4ff3e9628bcc67b4fb", size = 3785941, upload-time = "2026-02-25T22:33:43.585Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/f754a717f6b011050eb52ef09895cfa2f048f567f4aa3d5e0f773657dea4/onyx_devtools-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:505f9910a04868ab62d99bb483dc37c9f4ad94fa80e6ac0e6a10b86351c31420", size = 3832182, upload-time = "2026-02-25T22:33:43.283Z" },
{ url = "https://files.pythonhosted.org/packages/6a/35/6e653398c62078e87ebb0d03dc944df6691d92ca427c92867309d2d803b7/onyx_devtools-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:edec98e3acc0fa22cf9102c2070409ea7bcf99d7ded72bd8cb184ece8171c36a", size = 3576948, upload-time = "2026-02-25T22:33:42.962Z" },
{ url = "https://files.pythonhosted.org/packages/3c/97/cff707c5c3d2acd714365b1023f0100676abc99816a29558319e8ef01d5f/onyx_devtools-0.6.2-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:97abab61216866cdccd8c0a7e27af328776083756ce4fb57c4bd723030449e3b", size = 3439359, upload-time = "2026-02-25T22:33:44.684Z" },
{ url = "https://files.pythonhosted.org/packages/fc/98/3b768d18e5599178834b966b447075626d224e048d6eb264d89d19abacb4/onyx_devtools-0.6.2-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:681b038ab6f1457409d14b2490782c7a8014fc0f0f1b9cd69bb2b7199f99aef1", size = 3785959, upload-time = "2026-02-25T22:33:44.342Z" },
{ url = "https://files.pythonhosted.org/packages/d6/38/9b047f9e61c14ccf22b8f386c7a57da3965f90737453f3a577a97da45cdf/onyx_devtools-0.6.2-py3-none-win_amd64.whl", hash = "sha256:a2063be6be104b50a7538cf0d26c7f7ab9159d53327dd6f3e91db05d793c95f3", size = 3878776, upload-time = "2026-02-25T22:33:45.229Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0f/742f644bae84f5f8f7b500094a2f58da3ff8027fc739944622577e2e2850/onyx_devtools-0.6.2-py3-none-win_arm64.whl", hash = "sha256:00fb90a49a15c932b5cacf818b1b4918e5b5c574bde243dc1828b57690dd5046", size = 3501112, upload-time = "2026-02-25T22:33:41.512Z" },
{ url = "https://files.pythonhosted.org/packages/84/e2/e7619722c3ccd18eb38100f776fb3dd6b4ae0fbbee09fca5af7c69a279b5/onyx_devtools-0.6.3-py3-none-any.whl", hash = "sha256:d3a5422945d9da12cafc185f64b39f6e727ee4cc92b37427deb7a38f9aad4966", size = 3945381, upload-time = "2026-03-05T20:39:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/f2/09/513d2dabedc1e54ad4376830fc9b34a3d9c164bdbcdedfcdbb8b8154dc5a/onyx_devtools-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:efe300e9f3a2e7ae75f88a4f9e0a5c4c471478296cb1615b6a1f03d247582e13", size = 3978761, upload-time = "2026-03-05T20:39:28.822Z" },
{ url = "https://files.pythonhosted.org/packages/39/41/e757602a0de032d74ed01c7ee57f30e57728fb9cd4f922f50d2affda3889/onyx_devtools-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:594066eed3f917cfab5a8c7eac3d4a210df30259f2049f664787749709345e19", size = 3665378, upload-time = "2026-03-05T20:44:22.696Z" },
{ url = "https://files.pythonhosted.org/packages/33/1c/c93b65d0b32e202596a2647922a75c7011cb982f899ddfcfd171f792c58f/onyx_devtools-0.6.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:384ef66030b55c0fd68b3898782b5b4b868ff3de119569dfc8544e2ce534b98a", size = 3540890, upload-time = "2026-03-05T20:39:28.886Z" },
{ url = "https://files.pythonhosted.org/packages/f4/33/760eb656013f7f0cdff24570480d3dc4e52bbd8e6147ea1e8cf6fad7554f/onyx_devtools-0.6.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e218f3a49f64910c2c4c34d5dc12d1ea1520a27e0b0f6e4c0949ff9abaf0e1", size = 3945396, upload-time = "2026-03-05T20:39:34.323Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/f54b3675c464df8a51194ff75afc97c2417659e3a209dc46948b47c28860/onyx_devtools-0.6.3-py3-none-win_amd64.whl", hash = "sha256:8af614ae7229290ef2417cb85270184a1e826ed9a3a34658da93851edb36df57", size = 4045936, upload-time = "2026-03-05T20:39:28.375Z" },
{ url = "https://files.pythonhosted.org/packages/04/b8/5bee38e748f3d4b8ec935766224db1bbc1214c91092e5822c080fccd9130/onyx_devtools-0.6.3-py3-none-win_arm64.whl", hash = "sha256:717589db4b42528d33ae96f8006ee6aad3555034dcfee724705b6576be6a6ec4", size = 3608268, upload-time = "2026-03-05T20:39:28.731Z" },
]
[[package]]

View File

@@ -7,7 +7,7 @@ import SidebarTab from "@/refresh-components/buttons/SidebarTab";
import { SvgSliders } from "@opal/icons";
import { useUser } from "@/providers/UserProvider";
import { useAuthType } from "@/lib/hooks";
import { AuthType } from "@/lib/constants";
import { Section } from "@/layouts/general-layouts";
interface LayoutProps {
children: React.ReactNode;
@@ -28,9 +28,12 @@ export default function Layout({ children }: LayoutProps) {
<SettingsLayouts.Header icon={SvgSliders} title="Settings" separator />
<SettingsLayouts.Body>
<div className="grid grid-cols-[auto_1fr]">
<Section flexDirection="row" alignItems="start" gap={1.5}>
{/* Left: Tab Navigation */}
<div className="flex flex-col px-2 w-[12.5rem]">
<div
data-testid="settings-left-tab-navigation"
className="flex flex-col px-2 min-w-[12.5rem]"
>
<SidebarTab
href="/app/settings/general"
selected={pathname === "/app/settings/general"}
@@ -60,8 +63,8 @@ export default function Layout({ children }: LayoutProps) {
</div>
{/* Right: Tab Content */}
<div className="px-4">{children}</div>
</div>
{children}
</Section>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
</AppLayouts.Root>

View File

@@ -68,7 +68,9 @@ export default function CreateProjectModal({
<Button prominence="secondary" onClick={() => modal.toggle(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>Create Project</Button>
<Button disabled={!projectName.trim()} onClick={handleSubmit}>
Create Project
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>

View File

@@ -35,7 +35,7 @@ export const widthClassmap: Record<Length, string> = {
export const heightClassmap: Record<Length, string> = {
auto: "h-auto",
fit: "h-fit",
full: "h-full",
full: "h-full min-h-0",
};
/**

View File

@@ -515,10 +515,16 @@ const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
ref={ref}
className={cn(
twoTone && "bg-background-tint-01",
"h-full min-h-0 overflow-y-auto w-full"
"flex-auto min-h-0 overflow-y-auto w-full"
)}
>
<Section padding={1} gap={1} alignItems="start" {...props}>
<Section
height="auto"
padding={1}
gap={1}
alignItems="start"
{...props}
>
{children}
</Section>
</div>

View File

@@ -13,6 +13,7 @@ import { usePathname, useRouter } from "next/navigation";
import { SvgAlertTriangle, SvgLogOut } from "@opal/icons";
import { Content } from "@opal/layouts";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { getExtensionContext } from "@/lib/extension/utils";
export default function AppHealthBanner() {
const router = useRouter();
@@ -39,7 +40,18 @@ export default function AppHealthBanner() {
// Function to handle the "Log in" button click
function handleLogin() {
setShowLoggedOutModal(false);
router.push("/auth/login");
const { isExtension } = getExtensionContext();
if (isExtension) {
// In the Chrome extension, open login in a new tab so OAuth popups
// work correctly (the extension iframe has no navigable URL origin).
window.open(
window.location.origin + "/auth/login",
"_blank",
"noopener,noreferrer"
);
} else {
router.push("/auth/login");
}
}
// Function to set up expiration timeout

View File

@@ -188,50 +188,42 @@ export default function ShareChatSessionModal({
<Section
justifyContent="start"
alignItems="stretch"
gap={1}
height="auto"
gap={0.12}
>
<Section
justifyContent="start"
alignItems="stretch"
height="auto"
gap={0.12}
>
<PrivacyOption
icon={SvgLock}
title="Private"
description="Only you have access to this chat."
selected={selectedPrivacy === "private"}
onClick={() => setSelectedPrivacy("private")}
ariaLabel="share-modal-option-private"
/>
<PrivacyOption
icon={SvgUsers}
title="Your Organization"
description="Anyone in your organization can view this chat."
selected={selectedPrivacy === "public"}
onClick={() => setSelectedPrivacy("public")}
ariaLabel="share-modal-option-public"
/>
</Section>
{isShared && (
<div aria-label="share-modal-link-input">
<InputTypeIn
readOnly
value={shareLink}
rightSection={
<CopyIconButton
getCopyText={() => shareLink}
tooltip="Copy link"
size="sm"
aria-label="share-modal-copy-link"
/>
}
/>
</div>
)}
<PrivacyOption
icon={SvgLock}
title="Private"
description="Only you have access to this chat."
selected={selectedPrivacy === "private"}
onClick={() => setSelectedPrivacy("private")}
ariaLabel="share-modal-option-private"
/>
<PrivacyOption
icon={SvgUsers}
title="Your Organization"
description="Anyone in your organization can view this chat."
selected={selectedPrivacy === "public"}
onClick={() => setSelectedPrivacy("public")}
ariaLabel="share-modal-option-public"
/>
</Section>
{isShared && (
<InputTypeIn
aria-label="share-modal-link-input"
readOnly
value={shareLink}
rightSection={
<CopyIconButton
getCopyText={() => shareLink}
tooltip="Copy link"
size="sm"
aria-label="share-modal-copy-link"
/>
}
/>
)}
</Modal.Body>
<Modal.Footer>
{!isShared && (

View File

@@ -156,10 +156,7 @@ test.describe("Share Chat Session Modal", () => {
expect(patchBody).toEqual({ sharing_status: "public" });
const linkInput = dialog.locator('[aria-label="share-modal-link-input"]');
await expect(linkInput).toBeVisible({ timeout: 5000 });
const inputValue = await linkInput.locator("input").inputValue();
expect(inputValue).toContain("/app/shared/");
await expect(linkInput).toHaveValue(/\/app\/shared\//, { timeout: 5000 });
await expect(submitButton).toHaveText("Copy Link");
await expect(dialog.getByText("Chat shared")).toBeVisible();

View File

@@ -0,0 +1,36 @@
import { expect, test } from "@playwright/test";
import { THEMES, setThemeBeforeNavigation } from "@tests/e2e/utils/theme";
import { expectScreenshot } from "@tests/e2e/utils/visualRegression";
test.use({ storageState: "admin_auth.json" });
for (const theme of THEMES) {
test.describe(`Settings pages (${theme} mode)`, () => {
test.beforeEach(async ({ page }) => {
await setThemeBeforeNavigation(page, theme);
});
test("should screenshot each settings tab", async ({ page }) => {
await page.goto("/app/settings");
await page.waitForLoadState("networkidle");
const nav = page.getByTestId("settings-left-tab-navigation");
const tabs = nav.locator("a");
const count = await tabs.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const tab = tabs.nth(i);
const href = await tab.getAttribute("href");
const slug = href ? href.replace("/app/settings/", "") : `tab-${i}`;
await tab.click();
await page.waitForLoadState("networkidle");
await expectScreenshot(page, {
name: `settings-${theme}-${slug}`,
});
}
});
});
}