Technical briefing for AI coding agents working on this repository.
RubyCoded is a Ruby gem (MIT, Ruby >= 3.3) that runs an AI coding assistant inside the terminal. It is not a web app and not a Rails project. The product is a Ratatui TUI: chat with an LLM, optionally let it edit the current working directory (agent mode), or produce a structured plan first (product plan mode).
This file has two jobs:
- Describe the current architecture (stack, runtime, tools, extensions).
- Tell agents how to work: agree behavior with the developer (Planning rules, BDD), then implement (Development rules, TDD).
User-facing docs: README.md. Release notes: CHANGELOG.md.
“Plan” means three different things in this project. Do not mix them:
| Term | What it is |
|---|---|
| Agent planning | This file’s Planning rules: BDD with the developer before writing code |
RuntimeMode.plan |
Product capability: the in-app assistant plans with read-only tools |
State#mode |
TUI modal (:chat, :login, :tool_confirmation, …) |
bundle install
bundle exec rake test
bundle exec rake rubocop
bundle exec rake # test + rubocop (same as CI default)
bundle exec exe/ruby_coded # run from a project directoryCI (.github/workflows/ci.yml): tests on Ruby 3.3 and 3.4, RuboCop on 3.3, then gem build. It deletes Gemfile.lock before bundling. A rust toolchain step exists because ratatui_ruby needs it.
User-visible changes: update CHANGELOG.md. Version bumps: lib/ruby_coded/version.rb.
| Layer | Library | Why it is here |
|---|---|---|
| TUI | ratatui_ruby ~> 1.4 |
Event loop, layout, widgets |
| LLM (API keys) | ruby_llm ~> 1.13 |
Chat, streaming, tool-calling for OpenAI/Anthropic keys |
| HTTP (ChatGPT OAuth) | faraday |
Codex Responses API + SSE |
| CLI prompts (pre-TUI) | tty-prompt |
Directory trust + first-run login |
| OAuth callback | webrick |
Localhost redirect http://localhost:1455/auth/callback |
| Width/display | unicode-display_width |
Input/cursor math in the TUI |
| Tests | minitest |
test/test_*.rb |
| Lint | rubocop |
Double quotes, metrics, TargetRubyVersion 3.3 |
Entry point: exe/ruby_coded → RubyCoded.start → Initializer.new. Packaged via ruby_coded.gemspec; version is RubyCoded::VERSION in lib/ruby_coded/version.rb (currently 0.4.0).
Project root at runtime is Dir.pwd, not the gem install path. Tools, skills, and markdown commands all resolve against the directory the user launched from.
The TUI is a classic orchestrator + shared state design. Chat::App wires components and runs the event loop. It should stay thin.
Initializer
UserConfig + AuthManager.configure_ruby_llm!
│
▼
Chat::App
├── Commands::Catalog + Skills::Catalog
├── State (mutex, dirty flag, messages, UI mode)
├── BridgeFactory → LLMBridge | CodexBridge
├── InputHandler (key/mouse → action Symbol)
├── CommandHandler (slash commands)
└── Renderer (draw from State snapshot)
flowchart LR
TUI[Ratatui poll] --> IH[InputHandler]
IH -->|action| ED[EventDispatch]
ED -->|slash| CH[CommandHandler]
ED -->|message| BR[Bridge send_async]
ED -->|y/n/a| BR
BR -->|background thread| API[RubyLLM or Codex SSE]
BR --> ST[State]
ED --> ST
ST --> R[Renderer]
| Piece | Lives in | Job |
|---|---|---|
Initializer |
lib/ruby_coded/initializer.rb |
Trust cwd, auth, pick model, construct App |
Chat::App |
chat/app.rb |
Construct, event loop, recreate bridge after login |
EventDispatch |
chat/app/event_dispatch.rb |
Map action symbols to methods |
State |
chat/state.rb + chat/state/*.rb |
Single mutable store for UI + session |
InputHandler |
chat/input_handler/ |
Modal-aware keyboard routing |
Renderer |
chat/renderer/ |
Clear + draw chat / status / input / overlays |
CommandHandler |
chat/command_handler/ |
/help, /agent, /plan, /login, markdown cmds |
BridgeFactory |
chat/bridge_factory.rb |
Choose backend from OpenAI auth_method |
LLMBridge |
chat/llm_bridge.rb |
RubyLLM path |
CodexBridge |
chat/codex_bridge.rb |
ChatGPT Codex path |
RuntimeMode |
chat/runtime_mode.rb |
chat / agent / plan (assistant capability) |
PromptBuilder |
chat/prompt_builder.rb |
System instructions + skills |
Tools::* |
lib/ruby_coded/tools/ |
Agent capabilities |
Auth::* |
lib/ruby_coded/auth/ |
Providers, credentials, OAuth, JWT |
Commands::* |
lib/ruby_coded/commands/ |
Unified slash catalog |
Skills::* |
lib/ruby_coded/skills/ |
Prompt overlays |
Plugins::* |
lib/ruby_coded/plugins/ |
Mixins into State/Input/Renderer/CommandHandler |
Keep App as orchestration. New behavior goes in a mixin or a new class under the matching namespace.
Plugin modules are included on the classes (State, InputHandler, Renderer, CommandHandler) in App#apply_plugin_extensions! before build_components! instantiates them. State#initialize then calls init_#{plugin_name} if the state extension defines it.
Do not confuse these (and do not confuse either with agent planning in Planning rules):
RuntimeModeon the bridge (@mode) — what the in-app model is allowed to do::chat,:agent,:plan. Source of truth for tools, mutation, confirmation policy, and skill selection.State#mode— what the TUI is showing::chat,:tool_confirmation,:login, model-select, plan clarification, etc.
State#agentic_mode is a UI/status flag kept in sync by BridgeCommon::ModeTransitions. Bridges must use RuntimeMode, not a pair of booleans.
| RuntimeMode | Tools | Writes | Confirmation |
|---|---|---|---|
chat |
none | no | n/a |
plan |
read-only registry | no | yes for non-safe |
agent |
full registry | yes | yes for non-safe |
Default at boot: agent (App#enable_default_agent_mode!).
BridgeCommon::AutoSwitch flips product plan → agent when there is a stored current_plan and the user message matches implementation phrasing (English and Spanish: implement, go ahead, hazlo, ejecut, …).
Initializerloads~/.ruby_coded/config.yaml, optionally asks “Do you trust this directory?”, ensures at least one provider is authenticated, configures RubyLLM, resolves the model (stored model if its provider is authenticated, else that provider’s default).App#runentersRatatuiRuby.run. Loop: redraw if dirty → poll OAuth if waiting →poll_event→dispatch_event.InputHandler#processreturnsnilor an action (:submit,:quit,:tool_approved, …). Login, tool confirmation, plan clarifier, model selector, and streaming each have their own keymap.:submiton/…→CommandHandler. Anything else →State#add_message(:user, …)+bridge.send_async.send_asyncmay auto-switch product plan→agent, resets tool-call counters, setsstreaming = true, and starts a background thread. The UI thread must not call the provider.- Chunks append to the last assistant message. Esc sets
@cancel_requestedand signalstool_cv. - Tool calls:
ExecutionPolicythen optional confirmation, then the tool body (ExecutionPipelinefor filesystem tools). Results go back to the model (:tool_result, truncated at 10_000 chars). - Product plan-mode post-process:
PlanClarificationParsermay open an overlay; otherwise the assistant text becomescurrent_plan.
Poll timeout: 0.016s idle, 0.05s while streaming. Redraws during streaming are throttled by State::MIN_RENDER_INTERVAL (0.05s).
- UI thread: poll, dispatch, render.
- Bridge thread: HTTP / SSE / RubyLLM
chat.ask. - OAuth callback thread: Webrick server; the UI loop polls
login_oauth_result.
State is the coordination board. Message mutations go through @mutex. Tool confirmation uses a ConditionVariable (@tool_cv): the bridge thread waits in poll_tool_decision; State#tool_confirmation_response= signals it.
messages_snapshot copies messages for the renderer (keyed by @message_generation) so the renderer never iterates the live array without a lock.
Message hash shape:
{ role:, content:, timestamp:, input_tokens:, output_tokens:, thinking_tokens:, cached_tokens:, cache_creation_tokens: }content is always a mutable string (String.new(...)) because # frozen_string_literal: true would freeze "". Roles used today: :user, :assistant, :system, :tool_call, :tool_result, :tool_pending.
When you add state, put it in chat/state/<concern>.rb as a mixin nested under class State, initialize it from State#initialize, and mark_dirty! on user-visible changes.
BridgeFactory#build: if stored OpenAI credentials exist and auth_method == "oauth" → CodexBridge; else LLMBridge. Both must expose the same public surface so App and CommandHandler stay backend-agnostic:
send_async, cancel!, approve_tool!, approve_all_tools!, reject_tool!, reset_chat!, reset_agent_session!, toggle_agentic_mode!, toggle_plan_mode!, agentic_mode?, plan_mode?.
Shared (do not fork): RuntimeMode, PromptBuilder, Tools::ExecutionPolicy, Tools::Registry, BridgeCommon (ModeTransitions, ToolFlow, AutoSwitch).
| LLMBridge | CodexBridge | |
|---|---|---|
| Transport | RubyLLM | Faraday SSE to https://chatgpt.com/backend-api/codex/responses |
| History | RubyLLM Chat object |
@conversation_history resent every request (store: false) |
| Tools | chat.with_tools + on_tool_call / on_tool_result |
Bridge executes tools, then continue_after_tools |
PromptBuilder chat_base |
:agentic (chat still gets the full agent prompt) |
:simple (short default in chat mode) |
| Tokens | RubyLLM response object | response.completed SSE event |
| Models | RubyLLM catalog | Chat::CodexModels (some pro_only, gated by JWT plan) |
PromptBuilder composes Tools::SystemPrompt / PlanSystemPrompt / a short chat string, then Skills::PromptFormatter.append.
If you change confirmation, mode transitions, or prompt assembly, change the shared layer and verify both bridges. After /login, App#recreate_bridge! rebuilds the bridge and restores the previous RuntimeMode.
Config file: ~/.ruby_coded/config.yaml.
user_config:
trusted_directories: [...]
model: gpt-5.4
providers:
openai: { auth_method: oauth|api_key, ... }
anthropic: { ... }One UserConfig instance is threaded Initializer → AuthManager → CredentialsStore → Chat::App. A second in-memory copy will overwrite OAuth tokens on the next set_config (this already shipped as a bug). See Invariants.
Auth is strategy + provider:
- Providers:
Auth::Providers::OpenAI,Auth::Providers::Anthropic(display name, URLs, key pattern, RubyLLM config key). - Strategies:
Strategies::ApiKeyStrategy,Strategies::OAuthStrategy(PKCE, browser open, refresh). - OpenAI OAuth uses ChatGPT (
originator: codex_cli_rs).Auth::JWTDecoderpullschatgpt_account_id(and plan type) from the access token with no extra JWT gem. - In-TUI
/loginis a wizard onState(:provider_select→:auth_method_select→:api_key_input|:oauth_waiting), rendered byrenderer/login_flow.rb. It does not suspend the TUI for atty-promptform.
Tools::BaseTool < RubyLLM::Tool. Declare description, risk :safe|:confirm|:dangerous, and params. execute keyword args must match params.
Tools::Registry instantiates tools with project_root:. TOOL_CLASSES is the full agent set; READONLY_TOOL_CLASSES is product plan mode. RubyLLM may send read_file_tool or ruby_coded--tools--read_file_tool; lookup is on the last -- segment.
Current tools: read_file, list_directory (safe); write_file, edit_file, create_directory, git_add, git_commit (confirm); delete_path, run_command (dangerous); git_status, git_diff (safe). Git tools subclass GitBaseTool (Open3, chdir: project_root, GIT_EDITOR=true, 5000-char truncate). RunCommandTool is 30s timeout, same env, chdir: project_root.
ExecutionPolicy (bridges, before execute):
- Risk via registry
- Budgets: 50 write rounds (counter resets with a system message), 200 total (raises
AgentIterationLimitError) - Warn at 80% of total
requires_confirmation?is false for:safe, false if auto-approve, else true whenRuntimeMode#requires_confirmation?
ExecutionPipeline (inside the tool):
File.expand_pathrelative to project rootrealpath(or expanded path if ENOENT — needed for writes that create files)- Reject if not
start_with?(project_root) - Optional
forbid_root yield(full_path)- Rescue
SystemCallError→{ error: "Filesystem error: …" }
Use BaseTool#run_pipeline for side-effecting filesystem tools. validate_path! is the lighter helper for custom read flows. Never File.write a model-supplied path without one of these.
Expected tool failures return { error: "…" } (string or that hash). Control-flow exceptions the bridges already rescue: ToolRejectedError, AgentCancelledError, AgentIterationLimitError.
Three extension mechanisms. Pick one. Do not invent a fourth. Planning rules require mapping each BDD scenario to one of these (or to a change inside an existing module).
| Commands | Skills | Plugins | |
|---|---|---|---|
| What | Explicit /foo |
Prompt overlay | Ruby mixins |
| Trigger | User types it | Mode + optional tag/trigger | Startup |
| Code | command_handler/ or markdown |
.rubycoded/skills/*.md |
Plugins::Base subclass |
| On-disk | {cwd}/.ruby_coded/commands/*.md |
{cwd}/.rubycoded/skills/*.md |
gem lib/ |
Paths are intentionally different (.ruby_coded vs .rubycoded).
Commands::Catalog merges markdown → plugin → core. Priority numbers: markdown 1, plugin 2, core 3 (higher wins). Core and plugin names reserve the slash; conflicting markdown is reported on /commands reload and ignored.
Markdown commands have no Ruby handler: CommandHandler#handle_markdown_command sends the file body (+ extra args) as a user prompt via send_async.
Skills: YAML frontmatter (name, description, modes, optional tags, trigger, priority). Skills::Catalog#relevant_skills_for prefers tag/trigger matches; if none match, all mode-compatible skills stay active. First duplicate name wins.
Plugins: subclass Plugins::Base, implement .plugin_name, optionally state_extension, input_extension, renderer_extension, command_handler_extension, commands, command_descriptions. Register with RubyCoded.register_plugin in lib/ruby_coded/plugins.rb. Reference: Plugins::CommandCompletion.
Agree behavior first (Planning rules), then implement with TDD (Development rules):
Commands::CoreProvider::DEFINITIONS(name,description,handler:,source: :core,usage:).- Mixin under
chat/command_handler/withcmd_*. includeit fromCommandHandler.- Tests in
test/test_*_commands.rb.
Same workflow (BDD, then TDD):
lib/ruby_coded/tools/<name>_tool.rb, subclassBaseTool(orGitBaseTool).risk,params,execute. Userun_pipelinefor filesystem paths.- Register in
Registry(TOOL_CLASSESandREADONLY_TOOL_CLASSESif read-only). - Tests: execute, risk, path escape, expected error hashes.
The codebase prefers one class per file in lib/, then mixins for metrics. When RuboCop Metrics/* fires, extract a module next to the class (llm_bridge/tool_call_handling.rb, state/messages.rb, renderer/chat_panel_*.rb). Mixins are nested in the class they extend:
module RubyCoded
module Chat
class State
module Messages
def add_message(role, content)
# ...
end
end
end
end
endDo not disable Metrics cops on production files to avoid extracting. Tests are already excluded from those cops in .rubocop.yml. Development rules require the suite and RuboCop to stay green.
# frozen_string_literal: trueon every Ruby file.- Double-quoted strings (
Style/StringLiterals). - Target Ruby 3.3; endless methods are used in plugin hooks (
def state_extension = nil). - Namespaces:
RubyCoded::Chat,Tools,Auth,Commands,Skills,Plugins,Strategies,Errors. - Predicate methods:
agentic_mode?,plan_mode?,streaming?,login_active?. - Bang methods mutate session/UI:
mark_dirty!,toggle_agentic_mode!,request_tool_confirmation!. - Constants are frozen (
MAX_TOOL_RESULT_CHARS = 10_000). - Frozen literals: mutable buffers use
String.new, not"". - Frozen hashes duplicated before mutation (
ZERO_TOKEN_USAGE.dup). - Prefer keyword arguments on
executeand public APIs. - Comments explain why or the module’s contract; class/module doc comments are the norm at the top of a file.
- Tools: return
{ error: }for business/filesystem failure; do not raise past the tool unless it is a bridge control-flow error. - Bridges: rescue rate limits with 2 retries and exponential backoff (
2s,4s); surface a system or failed-assistant message. - Auth:
RubyCoded::Errors::AuthError. - Codex HTTP:
Chat::CodexAPIErrorwithstatus.
When renaming, keep the bridge predicates (agentic_mode? / plan_mode?) and RuntimeMode API. Plugins and command handlers call those.
UserConfig#get_config / #set_config read/write user_config.* and persist YAML immediately.
Where tests live and how they are shaped. When to write them, and the red → green → refactor loop, are in Development rules. Agreed Given/When/Then scenarios from Planning rules should become test_* methods, not a second informal checklist.
- Minitest.
require "test_helper"then require the specific lib files (tests do not autoload the whole gem unless needed). - File:
test/test_<topic>.rb. Class:Test<Topic> < Minitest::Test.test_tools.rbhosts several classes in one file (allowed). - Filesystem tests:
Dir.mktmpdirasproject_root,FileUtils.remove_entryinteardown. - Cover path-escape (
../), missing files, risk levels, catalog merge/priority, and mode transitions. - Prefer exercising the class under test over spinning the full TUI. Renderer tests exist where layout/status behavior is asserted.
These rules apply when the agent is in planning / plan mode (Cursor Plan mode, or any “design first, then code” pass). This is not RubyCoded’s RuntimeMode.plan (that is the in-app assistant). Product plan mode is under Architecture.
Do not implement while planning. Bounce the idea with the developer using BDD until the behavior is small, testable, and agreed. Skip a full planning pass only for an obvious one-line fix the developer already specified.
- Restate the request as behavior, not as a file list. Who is the user, what do they do, what should they observe?
- Propose concrete Given / When / Then scenarios (happy path, at least one failure or edge case, and what is explicitly out of scope).
- Map each scenario to an extension mechanism: command, skill, plugin, tool, or a change inside an existing module. Do not invent a fourth extension type.
- Call out open questions, Invariants that might be affected, and whether both bridges need the same change.
- Stop and wait for the developer. Refine or drop scenarios until they confirm. Do not treat silence as approval.
- Only after that confirmation, write a short implementation outline (files, tests to add first, RuboCop/extraction risks). Then hand off to Development rules.
Scenarios should be specific enough to become Minitest examples. Vague goals (“improve the TUI”, “make tools safer”) are not a plan.
- Agree the plan with the developer first when the work is a new feature or a behavior change. See Planning rules.
- Use TDD for new behavior: turn an agreed scenario into a failing Minitest, then the minimum production code to pass, then refactor. Follow Code organization and style.
- Do not consider a change done while
bundle exec rake testorbundle exec rake rubocopis red. Fix failures before handing work back. - Follow RuboCop. Prefer extracting a mixin over disabling Metrics cops in
lib/. Tests are already excluded from those cops. - Add or update tests for every behavior change. Do not weaken assertions to make a change pass.
- If the change is user-visible, update
CHANGELOG.md. BumpRubyCoded::VERSIONonly when the developer asks for a release.
Hard constraints on the design. They are not a Ruby module. Planning and implementation must preserve them.
- Do not mix commands, skills, and plugins for one feature.
- Do not add write tools to product plan mode (
RuntimeMode.plan) or skip confirmation for:confirm/:dangerouswithout going throughExecutionPolicy. - Do not let tools operate outside
project_root. - Do not block the TUI thread on HTTP.
- Do not construct a second
UserConfigthat can clobber~/.ruby_coded/config.yaml. - Do not duplicate policy/mode/prompt logic in only one bridge.
- Do not commit secrets, credentials, or a real
config.yaml. - Do not treat
State#modeasRuntimeMode, and do not treat agent planning as product plan mode. - Paths: commands
.ruby_coded/commands, skills.rubycoded/skills. - Keep agent-only notes here; README/CHANGELOG only for user-visible behavior.