Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ repository, including Codex, Claude Code, and other terminal coding agents.

## Current Project Phase

Latest release tag: v0.9.20.
Latest release tag: v0.9.21.

Active milestone: none. All currently scoped GitHub issues were closed when
this file was last aligned (2026-05-27). Do not autonomously pick up new
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.9.21] - 2026-05-28

### Fixed

- Live-room terminal text selection works by default again by making mouse
capture opt-in with `COREROOM_MOUSE_CAPTURE=1`.
- Delegation-like host wording such as "check with @engineer" now emits a
visible no-follow-up hint instead of silently idling without dispatch.

## [0.9.20] - 2026-05-27

### Fixed
Expand Down Expand Up @@ -1725,7 +1734,8 @@ API stability, not feature completeness.
- **No timestamps in CREP events.** `cr cost --since` honors the log
file's mtime only; per-event timestamps land in v0.2.

[Unreleased]: https://github.com/spytensor/CoreRoom/compare/v0.9.20...HEAD
[Unreleased]: https://github.com/spytensor/CoreRoom/compare/v0.9.21...HEAD
[0.9.21]: https://github.com/spytensor/CoreRoom/compare/v0.9.20...v0.9.21
[0.9.20]: https://github.com/spytensor/CoreRoom/compare/v0.9.19...v0.9.20
[0.9.19]: https://github.com/spytensor/CoreRoom/compare/v0.9.18...v0.9.19
[0.9.18]: https://github.com/spytensor/CoreRoom/compare/v0.9.17...v0.9.18
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "coreroom"
version = "0.9.20"
version = "0.9.21"
edition = "2021"
rust-version = "1.88"
authors = ["Charlie Zhu <chaojie.zhu.cn@gmail.com>"]
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Disable that with `COREROOM_NO_UPDATE_CHECK=1` or
<summary>Don't have npm? Direct binary install.</summary>

```bash
TAG=v0.9.20
TAG=v0.9.21
ARCH=$(uname -m); case "$ARCH" in arm64|aarch64) ARCH=aarch64 ;; *) ARCH=x86_64 ;; esac
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
curl -fsSL "https://github.com/spytensor/CoreRoom/releases/download/${TAG}/cr-${TAG}-${OS}-${ARCH}.tar.gz" \
Expand Down Expand Up @@ -247,9 +247,9 @@ Useful commands:
replays the full event log when you need to audit what happened. Set
`COREROOM_VERBOSE_TOOLS=1` to opt the live REPL back into the full
per-tool trace stream when you need it inline.
- The live room captures mouse wheel events by default so wheel / PgUp / PgDn
all scroll Room history inside the TUI. Set `COREROOM_MOUSE_CAPTURE=0` if
you prefer your terminal's native selection/scroll behavior for a session.
- The live room leaves terminal-native text selection alone by default. PgUp /
PgDn scroll Room history inside the TUI; set `COREROOM_MOUSE_CAPTURE=1` if
you also want mouse wheel events captured by the TUI for a session.
- Permission prompts appear only while a decision is needed. Successful
once-only allows clear the prompt and stay out of the chat stream; session
approvals and denials remain visible because they change what the role can
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@spytensor/coreroom",
"version": "0.9.20",
"version": "0.9.21",
"description": "CoreRoom is the Engineering Control Room for AI Agents: host-led, GitHub-gated AI-assisted software engineering control.",
"keywords": [
"cli",
Expand Down
50 changes: 32 additions & 18 deletions src/console_room_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2604,22 +2604,21 @@ fn mouse_capture_enabled() -> bool {

fn mouse_capture_enabled_from(value: Option<&str>) -> bool {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
return false;
};
!matches!(
matches!(
value.to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off" | "disable" | "disabled"
"1" | "true" | "yes" | "on" | "enable" | "enabled"
)
}

fn write_enter_commands_with_mouse_capture<W: Write>(
mut writer: W,
enable_mouse_capture: bool,
) -> io::Result<()> {
// Mouse capture is on by default so the live room owns wheel
// events and can scroll its own Room history. Users who prefer the
// terminal's native selection/scroll behavior can opt out with
// `COREROOM_MOUSE_CAPTURE=0`.
// Mouse capture is opt-in so terminals keep native text selection
// by default. Users who prefer wheel events to scroll Room history
// inside the TUI can opt in with `COREROOM_MOUSE_CAPTURE=1`.
//
// Cursor visibility is intentionally *not* set here. ratatui's
// `Terminal::draw` shows or hides the cursor every frame based on
Expand Down Expand Up @@ -2954,33 +2953,44 @@ mod tests {
}

#[test]
fn write_enter_commands_does_not_hide_cursor_or_drop_mouse_capture() {
fn write_enter_commands_does_not_hide_cursor_or_enable_mouse_capture_by_default() {
// Regression for live-room "no visible cursor" bug: the alt-screen
// setup must not emit DECRST 25 (`CSI ?25 l`). ratatui's
// `Terminal::draw` is responsible for the cursor's visibility on
// every frame, so a one-shot `Hide` here races the composer's
// per-frame `set_cursor_position` call and leaves Ask without a
// visible caret.
//
// Regression for live-room mouse wheel history: the default path must
// still enable mouse capture so wheel events reach `handle_mouse`
// instead of the parent terminal scrollback.
// Regression for live-room native selection: the default path must
// not enable mouse capture, otherwise drag-select/copy is intercepted
// by the TUI.
let mut buf: Vec<u8> = Vec::new();
super::write_enter_commands_with_mouse_capture(&mut buf, true)
.expect("write enter commands");
super::write_enter_commands(&mut buf).expect("write enter commands");
let text = String::from_utf8(buf).expect("enter commands are valid utf8");
assert!(
!text.contains("\x1b[?25l"),
"enter commands must not hide the cursor: {text:?}"
);
assert!(
!text.contains("\x1b[?1000h") && !text.contains("\x1b[?1003h"),
"default enter commands should not enable mouse capture: {text:?}"
);
}

#[test]
fn write_enter_commands_can_enable_mouse_capture() {
let mut buf: Vec<u8> = Vec::new();
super::write_enter_commands_with_mouse_capture(&mut buf, true)
.expect("write enter commands");
let text = String::from_utf8(buf).expect("enter commands are valid utf8");
assert!(
text.contains("\x1b[?1000h") || text.contains("\x1b[?1003h"),
"default enter commands should enable mouse capture: {text:?}"
"opt-in enter commands should enable mouse capture: {text:?}"
);
}

#[test]
fn write_enter_commands_can_disable_mouse_capture() {
fn write_enter_commands_omits_mouse_capture_when_disabled() {
let mut buf: Vec<u8> = Vec::new();
super::write_enter_commands_with_mouse_capture(&mut buf, false)
.expect("write enter commands");
Expand All @@ -2992,17 +3002,21 @@ mod tests {
}

#[test]
fn mouse_capture_defaults_on_unless_explicitly_disabled() {
assert!(super::mouse_capture_enabled_from(None));
assert!(super::mouse_capture_enabled_from(Some("")));
fn mouse_capture_defaults_off_unless_explicitly_enabled() {
assert!(!super::mouse_capture_enabled_from(None));
assert!(!super::mouse_capture_enabled_from(Some("")));
assert!(super::mouse_capture_enabled_from(Some("1")));
assert!(super::mouse_capture_enabled_from(Some("true")));
assert!(super::mouse_capture_enabled_from(Some("yes")));
assert!(super::mouse_capture_enabled_from(Some("on")));
assert!(super::mouse_capture_enabled_from(Some("enable")));
assert!(super::mouse_capture_enabled_from(Some("enabled")));
assert!(!super::mouse_capture_enabled_from(Some("0")));
assert!(!super::mouse_capture_enabled_from(Some("false")));
assert!(!super::mouse_capture_enabled_from(Some("no")));
assert!(!super::mouse_capture_enabled_from(Some("off")));
assert!(!super::mouse_capture_enabled_from(Some("disable")));
assert!(!super::mouse_capture_enabled_from(Some("disabled")));
}

#[test]
Expand Down
90 changes: 90 additions & 0 deletions src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,30 @@ async fn send_and_drain(

let known: Vec<&str> = roles.keys().map(String::as_str).collect();
let route_instructions = extract_route_instructions(&current.role, &captured.text, &known);
if route_instructions.is_empty() {
let targets = unrouted_delegation_intent_targets(&captured.text, &known);
if !targets.is_empty() {
let examples = targets
.iter()
.take(3)
.map(|target| format!("@{target}: <brief>"))
.collect::<Vec<_>>()
.join("`, `");
room_io::emit_line(
sink.as_ref(),
format!(
" {} {}",
"↳".with(output::FADE),
format!(
"no follow-up dispatched: delegation-like wording mentioned {}; use `{examples}` to route",
role_mentions(&targets),
)
.with(output::DIM)
.italic(),
),
);
}
}

// Grounding gate: if the role's tool calls were systematically
// denied, don't auto-route its explicit `@<peer>` delegations. The
Expand Down Expand Up @@ -1625,6 +1649,72 @@ fn extract_route_instructions(
stabilize_route_instructions(out)
}

fn unrouted_delegation_intent_targets(text: &str, known_roles: &[&str]) -> Vec<String> {
let mut targets = Vec::new();
let mut in_fence = false;

for line in text.lines() {
let trimmed = line.trim_start();
let fence_marker_line = trimmed.starts_with("```") || trimmed.starts_with("~~~");
if fence_marker_line {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}

let lower = line.to_ascii_lowercase();
for role in known_roles {
if line_has_delegation_like_prose(&lower, role)
&& !targets.iter().any(|target| target == role)
{
targets.push((*role).to_owned());
}
}
}

targets
}

fn line_has_delegation_like_prose(lower: &str, role: &str) -> bool {
let mention = format!("@{}", role.to_ascii_lowercase());
let english_patterns = [
format!("ask {mention}"),
format!("check with {mention}"),
format!("consult {mention}"),
format!("delegate to {mention}"),
format!("get {mention}"),
format!("have {mention}"),
format!("ping {mention}"),
format!("route to {mention}"),
format!("send to {mention}"),
];
if english_patterns
.iter()
.any(|pattern| lower.contains(pattern))
{
return true;
}

[
format!("请 {mention}"),
format!("请{mention}"),
format!("让 {mention}"),
format!("让{mention}"),
format!("问 {mention}"),
format!("问{mention}"),
format!("咨询 {mention}"),
format!("咨询{mention}"),
format!("委派给 {mention}"),
format!("委派给{mention}"),
format!("派给 {mention}"),
format!("派给{mention}"),
]
.iter()
.any(|pattern| lower.contains(pattern))
}

fn flush_route_block(
out: &mut Vec<ParsedRouteInstruction>,
targets: &mut Vec<String>,
Expand Down
24 changes: 23 additions & 1 deletion src/repl/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@ fn snapshot_boot_dashboard_at_80() {
.trim_start_matches('\n')
.to_owned();
insta::assert_snapshot!(rendered, @r"
┌─ CoreRoom v0.9.20 ───────────────────────────────────────────────────────────┐
┌─ CoreRoom v0.9.21 ───────────────────────────────────────────────────────────┐
│ │
│ welcome back, Ada tips for getting started │
│ • type @role to send a task to a sp… │
Expand Down Expand Up @@ -1887,6 +1887,28 @@ fn route_instructions_require_explicit_task_separator() {
assert!(out.is_empty());
}

#[test]
fn unrouted_delegation_hint_detects_promise_without_route() {
use super::unrouted_delegation_intent_targets;
let known = &["host", "engineer", "backend"];
let text = "I'll check with @engineer and then synthesize the recommended approach.";
assert_eq!(
unrouted_delegation_intent_targets(text, known),
vec!["engineer".to_owned()]
);
}

#[test]
fn unrouted_delegation_hint_ignores_plain_status_mentions() {
use super::unrouted_delegation_intent_targets;
let known = &["host", "backend", "ci"];
let text = concat!(
"@backend 和 @ci 都给了完整只读命令清单,但都明确需要 @host 点头才执行。\n",
"Still waiting for @backend and @ci."
);
assert!(unrouted_delegation_intent_targets(text, known).is_empty());
}

#[test]
fn route_instructions_extract_targeted_blocks() {
use super::{extract_route_instructions, RouteInstruction};
Expand Down