Skip to content

fix(cli): use ASCII box/separator chars in CI and respect NO_COLOR in output-renderer#4310

Closed
PeterPonyu wants to merge 25 commits into
code-yeongyu:devfrom
PeterPonyu:split/cli-ascii-no-color
Closed

fix(cli): use ASCII box/separator chars in CI and respect NO_COLOR in output-renderer#4310
PeterPonyu wants to merge 25 commits into
code-yeongyu:devfrom
PeterPonyu:split/cli-ascii-no-color

Conversation

@PeterPonyu

@PeterPonyu PeterPonyu commented May 23, 2026

Copy link
Copy Markdown
Contributor

Split off from #4305 to keep the change focused.

Summary

  • src/cli/run/output-renderer.ts: suppress truecolor SGR escapes when NO_COLOR, CI, GITHUB_ACTIONS, or TERM=dumb is set.
  • src/cli/install-validators.ts and src/cli/doctor/format-verbose.ts: switch box/separator drawing characters to ASCII fallbacks when the terminal cannot render Unicode reliably (CI logs, dumb terminals).
  • Tests added for NO_COLOR suppression in renderAgentHeader and ASCII fallback in installer/doctor output.

Why split

PR #4305 grew to 156 files spanning team-mode, runtime-fallback, TUI, and several unrelated fixes. This commit (e9894191) is independent and reviewable on its own.

Verification

  • Existing CJK / Unicode-normalization tests in output-renderer.test.ts still pass alongside the new NO_COLOR test.
  • Doctor verbose formatter snapshot updated.

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com


Summary by cubic

Adds a roles·models feature to view and switch each role’s active model, with live application and an optional TUI sidebar. CLI output now uses ASCII in CI and skips truecolor when color is disabled or terminals can’t render it.

  • New Features

    • display config: show_models_on_session_start, show_models_on_fallback, auto_pick, auto_pick_budget.
    • Slash commands: /show-models, /pick <role> <provider/model> [--variant] [--persist], /auto-pick on|off.
    • Agent tool pick_model with per-role session budget; validates against the role’s primary/fallback chain.
    • Picks apply at runtime to output.message.model and chat params (variant); panel can auto-print on session start.
    • /pick --persist writes to config via JSONC edit + atomic write.
    • TUI: new ./tui export registers a “Roles · Models” sidebar; built via script/build-tui.ts. Optional peers @opentui/core, @opentui/solid; adds solid-js.
  • Bug Fixes

    • ASCII separators in doctor and ASCII +/-/| boxes in installer when CI/GITHUB_ACTIONS; suppress 24-bit color when NO_COLOR, CI, GITHUB_ACTIONS, or TERM=dumb.
    • Output tree glyph uses ASCII fallback in CI; roles·models panel injection now uses a valid TextPart shape; agent handoff marker XML-escapes names.

Written for commit bf39d62. Summary will update on new commits. Review in cubic

@code-yeongyu code-yeongyu force-pushed the dev branch 2 times, most recently from eb25d29 to 2bfad49 Compare May 23, 2026 16:00
@PeterPonyu PeterPonyu force-pushed the split/cli-ascii-no-color branch from 3044dd3 to d3875e8 Compare May 24, 2026 03:46
@PeterPonyu

Copy link
Copy Markdown
Contributor Author

recheck

PeterPonyu and others added 25 commits May 24, 2026 00:03
Lays the groundwork for surfacing each role's active model + provider so
users (and team leaders) can see what's actually answering.

- New `display` config block: show_models_on_session_start,
  show_models_on_fallback, auto_pick, auto_pick_budget. Threaded through
  mergeConfigs in plugin-config.ts.
- New src/features/roles-models/ module with role enumeration (14 known
  roles from AgentOverridesSchema), per-session state (overrides + budget
  cap), view-builder, and markdown-panel renderer.

Wiring for /show-models, /pick, /auto-pick commands and the pick_model
agent tool follows in subsequent commits. JSONC --persist writer and
runtime fallback-state integration deferred to follow-up PRs.

bun run typecheck: pass. New tests: 17/17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lights the feature up end-to-end from the user's perspective.

- New built-in command definitions (show-models, pick, auto-pick) added
  to the Zod enum, BuiltinCommandName union, and command definitions map.
- New command-handler.ts in roles-models/ dispatches the three commands,
  parses arguments (incl. --variant and --persist), and pushes the
  rendered panel or confirmation as a text part.
- /pick records a session-scoped override; --persist surfaces a deferred
  notice (JSONC writer coming in a follow-up).
- /auto-pick toggles a per-session runtime override that wins over the
  config default; resolveAutoPick(sessionID, config) gives the merged
  view used by the panel.
- command-execute-before factory now accepts pluginConfig and routes
  roles-models commands through the new handler.

bun run typecheck: pass. Touched tests (display schema, roles-models,
builtin-commands, command-execute-before): 29/29 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets agents (and team leaders) request a model swap for a role mid-
session. Mirrors the user-facing /pick command, but callable from inside
an agent loop.

- Registered only when display.auto_pick is true OR /auto-pick on has
  been issued in this session (resolveAutoPick handles the merge).
- Validates the requested model exists in the role's declared chain
  (primary + fallback_models). Unknown roles get the known-roles list
  back as an error.
- Each role has a per-session swap budget (display.auto_pick_budget,
  default 2). Exhaustion returns a clear error without mutating state.
- Tool registration follows the hashline_edit pattern: conditional
  toolsRecord spread into allTools, factory routed through
  ToolRegistryFactories so it can be mocked in tests.

bun run typecheck: pass. New + existing roles-models tests: 33/33 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When display.show_models_on_session_start is true, the roles-models
panel is injected into the user's first message of each session. Gated
by the config flag, idempotent per session via an internal
SESSION_PANEL_SHOWN set. Plays nicely with the existing
firstMessageVariantGate in chat-message.ts.

show_models_on_fallback is intentionally not wired in this commit — it
needs a runtime-fallback event subscription that's better scoped to its
own change. The config field is already in the schema so the follow-up
won't change the public API.

bun run typecheck: pass. roles-models + pick-model suites: 38/38 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bun build bundles cleanly but the declaration-emit step in the full
build pipeline surfaced two strictness issues that tsc --noEmit did
not. Bundle artifacts (dist/index.js, dist/cli/index.js) are correct,
but anyone running the full build script (which includes
--emitDeclarationOnly + JSON-schema regen) hit exit 1.

- discover.ts: fallback_models is a Zod union of string, string[],
  FallbackModelObject[], and the mixed array. The old map() assumed
  shape code-yeongyu#3. Normalize all four through normalizeFallbackEntry so the
  declared chain is consistent regardless of how a user wrote it.
- pick-model/index.ts: annotate createPickModelTool with ToolDefinition
  so the inferred return type doesn't reach into the @opencode-ai/plugin
  internal zod copy (TS2742).
- Regenerated assets/oh-my-opencode.schema.json to include the new
  display block in the JSON schema (auto-generated by build:schema).

bun run build: pass. Touched-area tests: 38/38 still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bug: /pick and the pick_model tool recorded a per-session override
in state.ts, but no hook ever read that map to actually substitute the
model. The slash command confirmation said "✓ /pick applied" yet
subsequent LLM turns kept running on whatever opencode had picked
originally — verified in a live opencode run.

Root cause: the override map was wired only into the panel renderer
(view.ts → buildViews). The chat.message hook, which is where
output.message.model is set, never consulted it.

Fix:
- New active-model.ts exposes resolveOverrideModel(sessionID, role)
  which reads the override map and parses "provider/model" into
  {providerID, modelID}. Splits on the first slash so namespaced model
  ids like "vercel/openai/gpt-5.5" survive.
- chat-message.ts now calls resolveOverrideModel right after the
  existing getStoredMainSessionModel block (so /pick wins over the
  recovered session model). The result feeds output.message.model and
  flows through the existing setSessionModel persistence path, so the
  override sticks across subsequent turns in the same session.

Tests:
- 9 new tests in active-model.test.ts cover parseProviderModel edge
  cases (missing slash, leading/trailing slash, namespaced model ids)
  and resolveOverrideModel session/role scoping.
- 3 new integration tests in chat-message.test.ts assert that an
  override set via setOverride results in output.message.model being
  the parsed pair, that the pick wins over a stored session model,
  and that picks for other roles don't leak.

Out of scope (deferred):
- Variant overrides — output.message.model only carries
  {providerID, modelID}. Variant lives in chat.params (reasoning_effort
  etc.); a parallel block there is a small follow-up.

bun run build: pass. Touched-area tests: 68/68 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the /pick override path. The prior commit made model swaps
take effect via chat.message; this one extends the same treatment to
variant. Without it, /pick sisyphus anthropic/claude-opus-4-7 --variant=max
swapped the model correctly but left reasoning effort / thinking config
driven by the original variant.

How it works:
- chat-params.ts reads getRolePick(sessionID, agentKey) at the top of
  the handler.
- If the override carries a variant, it overwrites
  normalizedInput.message.variant (and the underlying rawMessage) before
  resolveCompatibleModelSettings runs.
- The existing pipeline then translates the picked variant into
  output.options.reasoningEffort / output.options.thinking / temperature
  etc. based on the target model's capabilities.

Precedence: pick.variant > input.message.variant > config default. If
the pick was issued without a variant, the user's TUI selection is left
untouched — both behaviors are covered by tests.

bun run build: pass. chat-params + chat-message + roles-models suites:
77/77 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion

Live opencode test surfaced a schema error: when maybeAutoPrintPanel
pushed a bare {type:"text", text:"..."} into output.parts from the
chat.message hook, opencode 1.14.49's session.prompt.createUserMessage
rejected the request with "Missing key at [id]/[sessionID]/[messageID]"
and the message never saved. The unit tests passed because the test
harness builds the same loose shape, but the real opencode runtime
validates against the full TextPart schema:

    { id: string, sessionID: string, messageID: string, type: "text", text: string }

Fix:
- maybeAutoPrintPanel now takes messageID alongside sessionID and
  constructs the part with id: prt_<uuid>, sessionID, messageID. The
  callsite in chat-message.ts passes input.messageID (already in
  opencode's hook contract; the plugin's narrower ChatMessageInput
  type just didn't expose it — added now).
- If messageID is undefined (test mocks, edge cases) the function
  skips injection silently — we can't construct a valid part without
  it and we'd rather no-op than crash.
- Tests updated for the new signature and a new assertion verifies
  the part carries id/sessionID/messageID with the expected shape.

Note: /show-models, /pick, /auto-pick are unaffected — they flow
through command.execute.before, which is a different output path that
doesn't hit the same schema check (confirmed live).

bun run build: pass. Touched-area tests: 85/85 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ut.agent is absent

cubic-dev-ai flagged that resolveOverrideModel was passed undefined
whenever input.agent was missing, silently skipping an active /pick
override for that turn. Identified by cubic. The pattern matters
because opencode occasionally invokes chat.message without input.agent
populated (compaction retries, model-fallback re-fires).

Fix: when input.agent is absent, look up the session's primary agent
via getSessionAgent(sessionID) and resolve the override against that.
The session-agent map is first-write-wins, so it stably represents
the agent the user originally selected for the session.

Tests: new chat-message integration test exercises the agent-absent
path; existing 28 chat-message tests still pass (29/29 total).

bun run build: pass. bun run typecheck: pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…VariantGate

Live qa-tester captured a reproducible miss: on opencode reconnects to
existing sessions (no session.created emitted to the plugin), the
firstMessageVariantGate's `pending` set stays empty, so
`shouldOverride` returns false, so the previous code path skipped
`maybeAutoPrintPanel` entirely. The panel only fired for truly-fresh
sessions, which is a narrower contract than the feature intends.

maybeAutoPrintPanel already has its own per-session idempotency via
SESSION_PANEL_SHOWN. Pulling it out of the isFirstMessage gate lets it
fire on the first chat.message turn of EVERY session within the plugin
lifetime — including reconnects — while the SESSION_PANEL_SHOWN check
prevents duplicate injection within the same session.

Tests: 3 new chat-message integration tests:
- panel fires when shouldOverride returns false (reconnect case)
- two consecutive chat.message calls inject exactly one panel (idempotent)
- show_models_on_session_start=false still gates injection out (regression guard)

bun run build: pass. bun run typecheck: pass. Touched-area tests:
73/73 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When chat.message fires without an explicit input.agent, the override
resolution previously fell through to the session's stored primary agent,
which could pick the wrong model if the /pick override was set against a
different agent earlier in the session.

Now we skip the override entirely in this case and let opencode resolve
the model normally.

Addresses cubic-dev-ai's first-pass review on PR code-yeongyu#4002 (severity 5/10,
confidence 7/10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ic write (Path A)

Replaces the Path B stub (reject --persist with code-yeongyu#4011 error) with the real
implementation, addressing the maintainer's design guidance in
code-yeongyu#4011 (comment).

Reuse anchors honoured:
1. JSONC mutation uses `jsonc-parser modify + applyEdits` — same pattern as
   `src/shared/migrate-legacy-plugin-entry.ts`. Comments, trailing commas,
   and existing formatting are preserved.
2. Atomic write via `writeFileAtomically` (write to <path>.tmp, then
   `renameSync` — POSIX-atomic on Linux/macOS APFS/HFS+). Windows EPERM
   fallback handled inside `writeFileAtomically`.
3. Path resolution routes through `getOmoConfigPath()` from
   `src/cli/config-manager/config-context.ts` — the same resolver used by
   `writeOmoConfig` and `loadPluginConfig`. No hardcoded platform paths.
4. Legacy `oh-my-opencode.json[c]` migration fallback is transparent:
   `getOmoConfigPath()` already calls `detectPluginConfigFile`, which
   resolves whichever file exists (canonical or legacy-migrated).
5. In-memory `pluginConfig` is mutated after the write so the current
   session sees the new model immediately. The next `loadPluginConfig` call
   reads the updated file from disk.

On failure (permission denied, parent dir not creatable, etc.) a structured
error is returned including the resolved path and underlying errno. No silent
fallback to session-only.

Tests added (8 scenarios, all tmpdir-based, no writes to real ~/.config):
- Writes agents.<role>.model to config with empty-object file
- Writes both model and variant with --variant=X
- Preserves existing comments in .jsonc fixture
- Overwrites existing agents.<role>.model
- Creates agents block when missing
- Returns structured error on write failure
- Verifies .tmp file is not left behind (atomic rename confirmed)
- Mutates in-memory config so session sees new value immediately

Thank you to @code-yeongyu for the precise reuse anchors in code-yeongyu#4011 —
this implementation follows them line-by-line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
  Adds the TUI plugin module that registers a "Roles · Models" section
  in the host sidebar_content slot. The TUI module is fully decoupled
  from the server module and reads only api.state.config and session
  messages.

  - src/tui/index.tsx: TUI entry point, registers sidebar_content slot
  - src/tui/sidebar/roles-models-section.tsx: SolidJS sidebar component
  - src/tui/sidebar/derive-row.ts: Pure data derivation for RoleRow
  - src/tui/sidebar/use-session-role-activity.ts: Reactive session hook
  - src/tui/sidebar/index.test.ts: 14 tests (all passing)
  - script/build-tui.ts: Build using @opentui/solid/bun-plugin
  - package.json: ./tui export, build:tui script, bump deps
  - tsconfig.json: jsx preserve + jsxImportSource for TSX

  Fixes code-yeongyu#4303
… output-renderer

Terminal emulators in CI (GitHub Actions and generic CI) do not render
box-drawing Unicode characters reliably, producing mojibake in logs.
format-verbose.ts and install-validators.ts now detect CI/GITHUB_ACTIONS
at module load time and substitute ASCII equivalents (-, |, +) for the
Unicode line/box chars.

output-renderer.ts: colorizeWithProfileColor now skips truecolor ANSI
escape sequences when NO_COLOR, CI, GITHUB_ACTIONS, or TERM=dumb is set,
preventing raw \e[38;2;... codes from leaking into non-color terminals.

output-renderer.test.ts (new): verifies that NO_COLOR suppresses
truecolor escapes from renderAgentHeader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@PeterPonyu PeterPonyu force-pushed the split/cli-ascii-no-color branch from c77c639 to bf39d62 Compare May 24, 2026 04:05
@PeterPonyu

Copy link
Copy Markdown
Contributor Author

Closing in favor of scope-isolated replacements.

This branch accidentally stacked 25 commits across 4 distinct scopes:

#4455 carries only the title-scoped changes here (94 net additions across 6 files, all CLI). The 97ae452 "restore decomposed Unicode in CJK coverage test" commit was dropped because the renderer normalizes to NFC and the test would assert against decomposed bytes — that landed in error during the stacking.

Apologies for the noise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant