Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
0fe0e499c0 chore(actions)(deps): bump actions/create-github-app-token
Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 1.9.3 to 3.2.0.
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](7bfa3a4717...bcd2ba4921)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 17:11:44 +00:00
alt-glitch
b62af47da8 chore: drop stale line-number reference in PRIORITY path comment
The cherry-pick comment referenced 'line ~6771' for the /stop handler,
but on current main the handler is at a different offset. Remove the
hard-coded line number — the 'above' reference is sufficient.
2026-05-25 16:23:24 +00:00
xxxigm
737ee81167 test(gateway): regression tests for #30170 subagent interrupt protection
17 new tests in tests/gateway/test_subagent_protection_30170.py pin
down both the detection helper and the demotion behaviour:

  * TestAgentHasActiveSubagents — 11 cases covering the precision and
    defensiveness of _agent_has_active_subagents:
      - returns False for None, _AGENT_PENDING_SENTINEL, and stub
        agents that lack the _active_children attribute;
      - returns False for an empty list (the steady state of an idle
        AIAgent);
      - returns True for one or many children;
      - works when _active_children_lock is None (test stubs);
      - rejects truthy MagicMock auto-attributes — this is the
        regression-guard for "every MagicMock-based gateway test
        suddenly demotes to queue mode" (which is how this was
        originally found);
      - accepts list/tuple/set as the children container.

  * TestBusyHandlerDemotesInterruptForSubagents — 6 cases driving
    _handle_active_session_busy_message directly:
      - parent.interrupt is NOT called when subagents are active,
        message is still merged into the pending queue;
      - ack copy mentions "Subagent working", "queued", and the
        /stop escape hatch — and does NOT mention "Interrupting";
      - with no subagents, behaviour is byte-identical to the
        pre-#30170 interrupt path (parent.interrupt called with the
        user text, ack says "Interrupting");
      - configured queue mode keeps its vanilla "Queued for the next
        turn" ack (the #30170 demotion-specific copy must NOT fire);
      - configured steer mode still routes to running_agent.steer()
        even when subagents are active (the guard is interrupt-only);
      - _AGENT_PENDING_SENTINEL does not trigger demotion.

Refs #30170.
2026-05-25 16:23:24 +00:00
xxxigm
99d62f6ba1 fix(gateway): protect in-flight subagents from busy-mode interrupts (#30170)
When a user sends a conversational follow-up while delegate_task is
running, gateway/run.py calls running_agent.interrupt(event.text) on
the PARENT agent. AIAgent.interrupt() then cascades synchronously
through self._active_children and calls interrupt() on every child
subagent, aborting in-flight delegate_task work. The user sees the
fallback cascade with no root-cause in the gateway log, and minutes of
subagent progress are destroyed — the exact failure mode reported in

Add GatewayRunner._agent_has_active_subagents(running_agent) — a
static helper that returns True iff the parent is currently driving
subagents via delegate_task. The helper is type-defensive: it ignores
truthy MagicMock auto-attributes (so this doesn't accidentally fire
in every test mock that hits the busy path), the _AGENT_PENDING_SENTINEL
placeholder, and missing locks.

Wire the helper into both interrupt branches:

  1. _handle_active_session_busy_message — the adapter-level busy
     handler. When busy_input_mode == 'interrupt' AND the parent has
     active subagents, demote to 'queue' semantics: skip the
     parent.interrupt() call, merge the message into the pending
     queue, and surface a dedicated ack (" Subagent working — your
     message is queued for when it finishes (use /stop to cancel
     everything).") so the operator knows the message wasn't lost and
     discovers the explicit escape hatch.

  2. The PRIORITY interrupt branch inside _handle_message — the
     non-command fast path. Same rationale, same demotion. Routes
     through _queue_or_replace_pending_event so the next-turn pickup
     stays unchanged.

Explicit /stop and /new commands take a completely different path
(_interrupt_and_clear_session in the slash-command dispatch at line
~6771) and are NOT affected by this guard — the operator still has a
way to force-cancel everything when they actually mean it. Configured
'queue' and 'steer' modes are also untouched: 'queue' already does the
right thing, and 'steer' goes through running_agent.steer() which does
NOT cascade to children (so subagents survive a steer too).

This is Phase 1 of the fix outlined in #30170 — the minimum viable
change that stops subagent loss. Phase 2 (delegation-aware steer
forwarding to active children) and Phase 3 (async delegation, #11508)
are intentionally out of scope.

Refs #30170.
2026-05-25 16:23:24 +00:00
brooklyn!
50aaf0c4ad fix(tui): delineate assistant responses from details (#31087)
* fix(tui): delineate assistant responses from details

Add a muted Response marker before assistant text when thinking/tool details are visible so reasoning and final output do not visually run together.

* fix(tui): account for response separator height

Keep virtual transcript estimates aligned with the new response separator and avoid allocating trimmed copies of long assistant text.

* fix(tui): gate response separator estimate on details

Only add response-separator height when assistant details actually render, and use a non-allocating body-text check.

* fix(tui): skip empty detail height estimates

Do not add virtual transcript height for assistant details when no thinking or tool detail UI will render.

* fix(tui): estimate details by section visibility

Pass resolved thinking/tool visibility into virtual height estimates so hidden detail sections do not reserve response-separator rows.
2026-05-25 10:23:03 -05:00
brooklyn!
0ec0cafdd0 Merge pull request #31084 from NousResearch/bb/tui-right-click-copy-selection
fix(tui): right-click copies active transcript selection
2026-05-25 10:22:43 -05:00
11 changed files with 659 additions and 6 deletions

View File

@@ -51,7 +51,7 @@ jobs:
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

View File

@@ -3034,6 +3034,44 @@ class GatewayRunner:
if agent is not _AGENT_PENDING_SENTINEL
}
@staticmethod
def _agent_has_active_subagents(running_agent: Any) -> bool:
"""Return True when *running_agent* is currently driving subagents
via the ``delegate_task`` tool.
Background (#30170): ``AIAgent.interrupt()`` cascades through the
parent's ``_active_children`` list and calls ``interrupt()`` on
every child synchronously, which aborts in-flight subagent work
and produces a fallback cascade with no actionable signal.
Demoting ``busy_input_mode='interrupt'`` to ``queue`` semantics
whenever this helper returns True protects subagent work from
conversational follow-ups while leaving the explicit ``/stop``
path (which goes through ``_interrupt_and_clear_session``)
untouched. Safe-by-default: returns False on any attribute or
lock error so a missing/broken parent never blocks the existing
interrupt path.
"""
if running_agent is None or running_agent is _AGENT_PENDING_SENTINEL:
return False
children = getattr(running_agent, "_active_children", None)
# AIAgent always initialises this as a concrete list (see
# agent/agent_init.py). Reject anything that isn't a real
# collection — this guards against ``MagicMock()._active_children``
# auto-creating a truthy stub in tests and triggering the demotion
# against an agent that doesn't actually have subagents.
if not isinstance(children, (list, tuple, set)):
return False
if not children:
return False
lock = getattr(running_agent, "_active_children_lock", None)
try:
if lock is not None:
with lock:
return bool(children)
return bool(children)
except Exception:
return False
def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None:
adapter = self.adapters.get(event.source.platform)
if not adapter:
@@ -3105,6 +3143,25 @@ class GatewayRunner:
# queueing + interrupting. If the agent isn't running yet
# (sentinel) or lacks steer(), or the payload is empty, fall back
# to queue semantics so nothing is lost.
# #30170 — Subagent protection. ``AIAgent.interrupt()`` cascades
# to every entry in the parent's ``_active_children`` list and
# aborts in-flight ``delegate_task`` work. Demote ``interrupt``
# to ``queue`` when the parent is currently driving subagents so
# a conversational follow-up doesn't destroy minutes of subagent
# work. Explicit ``/stop`` and ``/new`` slash commands go through
# ``_interrupt_and_clear_session`` and are unaffected — the
# operator still has a way to force-cancel everything.
demoted_for_subagents = (
effective_mode == "interrupt"
and self._agent_has_active_subagents(running_agent)
)
if demoted_for_subagents:
logger.info(
"Demoting busy_input_mode 'interrupt' to 'queue' for session %s "
"because the running agent has active subagents (#30170)",
session_key,
)
effective_mode = "queue"
steered = False
if effective_mode == "steer":
steer_text = (event.text or "").strip()
@@ -3192,6 +3249,14 @@ class GatewayRunner:
f"⏩ Steered into current run{status_detail}. "
f"Your message arrives after the next tool call."
)
elif is_queue_mode and demoted_for_subagents:
# #30170 — explain the demotion so the user knows their
# follow-up didn't accidentally kill the subagent and
# discovers `/stop` as the explicit escape hatch.
message = (
f"⏳ Subagent working{status_detail} — your message is queued for "
f"when it finishes (use /stop to cancel everything)."
)
elif is_queue_mode:
message = (
f"⏳ Queued for the next turn{status_detail}. "
@@ -7246,6 +7311,22 @@ class GatewayRunner:
logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key)
self._queue_or_replace_pending_event(_quick_key, event)
return None
# #30170 — Subagent protection (PRIORITY path). Same rationale
# as ``_handle_active_session_busy_message``: an interrupt
# cascades through ``_active_children`` and aborts in-flight
# delegate_task work. Demote to queue semantics when the
# parent is currently driving subagents so a conversational
# follow-up doesn't destroy minutes of subagent progress.
# /stop reaches its dedicated handler above, so the operator
# still has a clean escape hatch.
if self._agent_has_active_subagents(running_agent):
logger.info(
"PRIORITY interrupt demoted to queue for session %s "
"because the running agent has active subagents (#30170)",
_quick_key,
)
self._queue_or_replace_pending_event(_quick_key, event)
return None
logger.debug("PRIORITY interrupt for session %s", _quick_key)
running_agent.interrupt(event.text)
# NOTE: self._pending_messages was write-only (never consumed).

View File

@@ -0,0 +1,348 @@
"""Regression tests for #30170.
#30170: Sending a message while ``delegate_task`` is running killed the
subagent because the gateway always called ``running_agent.interrupt()``
on the parent, which then cascaded synchronously through
``AIAgent._active_children`` and aborted every in-flight subagent. The
reporter (and the linked Phase-1 spec) asked for the gateway to demote
``busy_input_mode='interrupt'`` to ``queue`` semantics whenever the
parent is currently driving subagents, while leaving explicit ``/stop``
and ``/new`` slash commands untouched.
These tests pin down the gateway-side guard introduced for #30170:
* ``GatewayRunner._agent_has_active_subagents`` correctly recognises
parents that own real children, without false-positives from a
``MagicMock()._active_children`` auto-attribute, missing locks, or
the ``_AGENT_PENDING_SENTINEL`` placeholder.
* ``_handle_active_session_busy_message`` demotes the interrupt mode to
queue semantics (no ``interrupt()`` call, message merged into the
pending queue, ack reflects the demotion) when the parent has active
subagents.
* The ``queue`` and ``steer`` configured modes still behave exactly as
before — the guard is interrupt-only.
"""
from __future__ import annotations
import sys
import threading
import time
import types
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ──────────────────────────────────────────────────────────────────────
# Minimal stubs so gateway imports cleanly (mirrors test_busy_session_ack)
# ──────────────────────────────────────────────────────────────────────
_tg = types.ModuleType("telegram")
_tg.constants = types.ModuleType("telegram.constants")
_ct = MagicMock()
_ct.SUPERGROUP = "supergroup"
_ct.GROUP = "group"
_ct.PRIVATE = "private"
_tg.constants.ChatType = _ct
sys.modules.setdefault("telegram", _tg)
sys.modules.setdefault("telegram.constants", _tg.constants)
sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext"))
from gateway.platforms.base import ( # noqa: E402
MessageEvent,
MessageType,
SessionSource,
build_session_key,
)
from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL # noqa: E402
# ──────────────────────────────────────────────────────────────────────
# Builders (parallel to tests/gateway/test_busy_session_ack.py)
# ──────────────────────────────────────────────────────────────────────
def _make_event(text: str = "hello", chat_id: str = "123") -> MessageEvent:
source = SessionSource(
platform=MagicMock(value="telegram"),
chat_id=chat_id,
chat_type="private",
user_id="user1",
)
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id="msg1",
)
def _make_runner() -> GatewayRunner:
runner = object.__new__(GatewayRunner)
runner._running_agents = {}
runner._running_agents_ts = {}
runner._pending_messages = {}
runner._busy_ack_ts = {}
runner._draining = False
runner.adapters = {}
runner.config = MagicMock()
runner.session_store = None
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.pairing_store = MagicMock()
runner.pairing_store.is_approved.return_value = True
runner._is_user_authorized = lambda _source: True
return runner
def _make_adapter() -> MagicMock:
adapter = MagicMock()
adapter._pending_messages = {}
adapter._send_with_retry = AsyncMock()
adapter.config = MagicMock()
adapter.config.extra = {}
adapter.platform = MagicMock(value="telegram")
return adapter
def _make_parent_with_subagents(
*, children: int = 1, with_lock: bool = True
) -> MagicMock:
"""A MagicMock shaped like an AIAgent that currently owns *children* subagents."""
parent = MagicMock()
parent._active_children = [MagicMock() for _ in range(children)]
parent._active_children_lock = threading.Lock() if with_lock else None
parent.get_activity_summary.return_value = {
"api_call_count": 7,
"max_iterations": 60,
"current_tool": "delegate_task",
}
return parent
def _make_parent_no_subagents() -> MagicMock:
"""A MagicMock shaped like an AIAgent that is NOT delegating."""
parent = MagicMock()
parent._active_children = []
parent._active_children_lock = threading.Lock()
parent.get_activity_summary.return_value = {
"api_call_count": 3,
"max_iterations": 60,
"current_tool": "terminal",
}
return parent
# ──────────────────────────────────────────────────────────────────────
# _agent_has_active_subagents
# ──────────────────────────────────────────────────────────────────────
class TestAgentHasActiveSubagents:
"""The detection helper must be both precise and defensive."""
def test_returns_false_for_none(self) -> None:
assert GatewayRunner._agent_has_active_subagents(None) is False
def test_returns_false_for_pending_sentinel(self) -> None:
assert (
GatewayRunner._agent_has_active_subagents(_AGENT_PENDING_SENTINEL)
is False
)
def test_returns_false_when_attribute_missing(self) -> None:
"""Production AIAgents always have _active_children, but the helper
must not blow up on test stubs or partial mocks."""
class StubAgent:
pass
assert GatewayRunner._agent_has_active_subagents(StubAgent()) is False
def test_returns_false_for_empty_list(self) -> None:
assert (
GatewayRunner._agent_has_active_subagents(_make_parent_no_subagents())
is False
)
def test_returns_true_for_single_child(self) -> None:
assert (
GatewayRunner._agent_has_active_subagents(_make_parent_with_subagents())
is True
)
def test_returns_true_for_many_children(self) -> None:
assert (
GatewayRunner._agent_has_active_subagents(
_make_parent_with_subagents(children=5)
)
is True
)
def test_works_without_lock(self) -> None:
"""``_active_children_lock`` is optional in test stubs."""
assert (
GatewayRunner._agent_has_active_subagents(
_make_parent_with_subagents(with_lock=False)
)
is True
)
def test_rejects_truthy_non_collection_attribute(self) -> None:
"""The MagicMock auto-attribute regression. ``MagicMock()._active_children``
is itself a truthy MagicMock — without the isinstance guard, the
helper would falsely report subagents on every test mock."""
parent = MagicMock() # no explicit _active_children setup
assert GatewayRunner._agent_has_active_subagents(parent) is False
@pytest.mark.parametrize(
"container",
[(MagicMock(),), {MagicMock()}, [MagicMock()]],
ids=["tuple", "set", "list"],
)
def test_accepts_list_tuple_set(self, container: Any) -> None:
parent = MagicMock()
parent._active_children = container
parent._active_children_lock = threading.Lock()
assert GatewayRunner._agent_has_active_subagents(parent) is True
# ──────────────────────────────────────────────────────────────────────
# _handle_active_session_busy_message — interrupt demotion
# ──────────────────────────────────────────────────────────────────────
class TestBusyHandlerDemotesInterruptForSubagents:
"""The Phase-1 fix from #30170: parent.interrupt() must NOT fire when
the parent is currently driving subagents."""
@pytest.mark.asyncio
async def test_does_not_call_interrupt_when_subagents_active(self) -> None:
runner = _make_runner()
runner._busy_input_mode = "interrupt"
adapter = _make_adapter()
event = _make_event(text="follow up while subagent runs")
sk = build_session_key(event.source)
parent = _make_parent_with_subagents()
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
with patch("gateway.run.merge_pending_message_event") as merge_mock:
handled = await runner._handle_active_session_busy_message(event, sk)
assert handled is True
parent.interrupt.assert_not_called()
# Message must still be queued so it gets picked up on the next turn.
merge_mock.assert_called_once()
@pytest.mark.asyncio
async def test_ack_explains_the_demotion(self) -> None:
"""The user-visible ack must mention the subagent context AND
the `/stop` escape hatch so the operator can self-correct."""
runner = _make_runner()
runner._busy_input_mode = "interrupt"
adapter = _make_adapter()
event = _make_event(text="hi mid-delegation")
sk = build_session_key(event.source)
parent = _make_parent_with_subagents()
runner._running_agents[sk] = parent
runner._running_agents_ts[sk] = time.time() - 120
runner.adapters[event.source.platform] = adapter
with patch("gateway.run.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
adapter._send_with_retry.assert_called_once()
content = adapter._send_with_retry.call_args.kwargs.get("content", "")
assert "Subagent working" in content
assert "queued" in content.lower()
assert "/stop" in content
assert "Interrupting" not in content
@pytest.mark.asyncio
async def test_interrupt_still_fires_when_no_subagents(self) -> None:
"""Regression-guard the other direction: with no subagents the
demotion must NOT trigger and behaviour must be byte-identical
to the pre-#30170 interrupt path."""
runner = _make_runner()
runner._busy_input_mode = "interrupt"
adapter = _make_adapter()
event = _make_event(text="please stop")
sk = build_session_key(event.source)
parent = _make_parent_no_subagents()
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
with patch("gateway.run.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
parent.interrupt.assert_called_once_with("please stop")
content = adapter._send_with_retry.call_args.kwargs.get("content", "")
assert "Interrupting" in content
assert "Subagent" not in content
@pytest.mark.asyncio
async def test_queue_mode_unchanged_with_subagents(self) -> None:
"""Configured ``queue`` mode is already subagent-safe; the new
guard must not change its behaviour or its ack text."""
runner = _make_runner()
runner._busy_input_mode = "queue"
adapter = _make_adapter()
event = _make_event(text="queued during delegate")
sk = build_session_key(event.source)
parent = _make_parent_with_subagents()
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
with patch("gateway.run.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
parent.interrupt.assert_not_called()
content = adapter._send_with_retry.call_args.kwargs.get("content", "")
# The vanilla queue copy — NOT the #30170 "Subagent working" copy,
# because the user explicitly asked for queue mode.
assert "Queued for the next turn" in content
assert "respond once the current task finishes" in content
assert "Subagent working" not in content
@pytest.mark.asyncio
async def test_steer_mode_still_routes_through_running_agent_steer(
self,
) -> None:
"""Configured ``steer`` mode must reach ``running_agent.steer()``
even when subagents are active — the #30170 demotion is
interrupt-specific so it doesn't accidentally disable steer."""
runner = _make_runner()
runner._busy_input_mode = "steer"
adapter = _make_adapter()
event = _make_event(text="course-correct")
sk = build_session_key(event.source)
parent = _make_parent_with_subagents()
parent.steer = MagicMock(return_value=True)
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
with patch("gateway.run.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
parent.steer.assert_called_once_with("course-correct")
parent.interrupt.assert_not_called()
@pytest.mark.asyncio
async def test_pending_sentinel_does_not_demote(self) -> None:
"""The placeholder ``_AGENT_PENDING_SENTINEL`` is not a real
agent — the guard must not treat it as having subagents.
Otherwise we'd permanently queue messages for sessions that
haven't actually started running yet."""
runner = _make_runner()
runner._busy_input_mode = "interrupt"
adapter = _make_adapter()
event = _make_event(text="follow up before start")
sk = build_session_key(event.source)
runner._running_agents[sk] = _AGENT_PENDING_SENTINEL
runner.adapters[event.source.platform] = adapter
with patch("gateway.run.merge_pending_message_event"):
handled = await runner._handle_active_session_busy_message(event, sk)
assert handled is True
# Sentinel can't be interrupted (no .interrupt to call) — verify
# that the helper still returns the "interrupting" copy because
# demotion did NOT fire (and the sentinel branch in the real
# handler just skips the interrupt call silently).
content = adapter._send_with_retry.call_args.kwargs.get("content", "")
assert "Subagent working" not in content

View File

@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest'
import { handleMouseEvent } from './components/App.js'
import { createSelectionState, startSelection, updateSelection } from './selection.js'
const makeApp = () => {
const selection = createSelectionState()
return {
clickCount: 1,
lastHoverCol: -1,
lastHoverRow: -1,
mouseCaptureTarget: undefined,
props: {
getSelectedText: vi.fn(() => 'selected text'),
onCopySelectionNoClear: vi.fn(async () => 'selected text'),
onHoverAt: vi.fn(),
onMouseDownAt: vi.fn(),
onMouseDragAt: vi.fn(),
onMouseUpAt: vi.fn(),
onSelectionChange: vi.fn(),
selection
}
} as any
}
describe('handleMouseEvent right-click selection behavior', () => {
it('copies an active selection instead of dispatching right-click paste handlers', async () => {
const app = makeApp()
startSelection(app.props.selection, 0, 0)
updateSelection(app.props.selection, 4, 0)
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
await Promise.resolve()
expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce()
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
expect(app.clickCount).toBe(0)
})
it('falls back to right-click handlers when selection copy has no clipboard path', async () => {
const app = makeApp()
app.props.onCopySelectionNoClear.mockResolvedValue('')
startSelection(app.props.selection, 0, 0)
updateSelection(app.props.selection, 4, 0)
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
await Promise.resolve()
expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce()
expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2)
})
it('does not paste when highlighted selection text is empty', async () => {
const app = makeApp()
app.props.getSelectedText.mockReturnValue('')
startSelection(app.props.selection, 0, 0)
updateSelection(app.props.selection, 4, 0)
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
await Promise.resolve()
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
})
it('does not repeatedly copy or paste during right-button motion events over a selection', () => {
const app = makeApp()
startSelection(app.props.selection, 0, 0)
updateSelection(app.props.selection, 4, 0)
handleMouseEvent(app, { action: 'press', button: 0x20 | 2, col: 3, kind: 'mouse', row: 1 })
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
})
it('still dispatches right-click handlers when no text is selected', () => {
const app = makeApp()
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2)
})
})

View File

@@ -76,6 +76,10 @@ type Props = {
// DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
readonly onHoverAt: (col: number, row: number) => void
// Copy the active fullscreen text selection without clearing the highlight.
// Used for terminal-native right-click-copy behaviour.
readonly onCopySelectionNoClear: () => Promise<string>
readonly getSelectedText: () => string
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
// time. Returns the URL or undefined. The browser-open is deferred by
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
@@ -631,6 +635,28 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
if (baseButton !== 0) {
// Non-left press breaks the multi-click chain.
app.clickCount = 0
if (baseButton === 2 && hasSelection(sel)) {
if ((m.button & 0x20) !== 0) {
return
}
if (!app.props.getSelectedText()) {
return
}
void app.props
.onCopySelectionNoClear()
.then(text => {
if (!text) {
app.props.onMouseDownAt(col, row, baseButton)
}
})
.catch(() => app.props.onMouseDownAt(col, row, baseButton))
return
}
app.props.onMouseDownAt(col, row, baseButton)
return

View File

@@ -1492,7 +1492,7 @@ export default class Ink {
return ''
}
const text = getSelectedText(this.selection, this.frontFrame.screen)
const text = this.getTextSelectionText()
if (text) {
try {
@@ -1514,6 +1514,10 @@ export default class Ink {
return ''
}
getTextSelectionText(): string {
return hasSelection(this.selection) ? getSelectedText(this.selection, this.frontFrame.screen) : ''
}
/**
* Copy the current text selection to the system clipboard via OSC 52
* and clear the selection. Returns the copied text (empty if no selection
@@ -2332,7 +2336,9 @@ export default class Ink {
dispatchKeyboardEvent={this.dispatchKeyboardEvent}
exitOnCtrlC={this.options.exitOnCtrlC}
getHyperlinkAt={this.getHyperlinkAt}
getSelectedText={this.getTextSelectionText}
onClickAt={this.dispatchClick}
onCopySelectionNoClear={this.copySelectionNoClear}
onCursorAdvance={this.noteExternalCursorAdvance}
onCursorDeclaration={this.setCursorDeclaration}
onExit={this.unmount}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { shouldShowResponseSeparator } from '../components/messageLine.js'
describe('shouldShowResponseSeparator', () => {
it('separates assistant response text from visible details', () => {
expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final', thinking: 'plan' }, true)).toBe(true)
})
it('does not add a response separator without details or body text', () => {
expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final' }, false)).toBe(false)
expect(shouldShowResponseSeparator({ role: 'assistant', text: ' ', thinking: 'plan' }, true)).toBe(false)
})
it('does not add response separators to non-assistant transcript rows', () => {
expect(shouldShowResponseSeparator({ role: 'user', text: 'prompt' }, true)).toBe(false)
expect(shouldShowResponseSeparator({ role: 'system', text: 'note' }, true)).toBe(false)
})
})

View File

@@ -32,6 +32,45 @@ describe('virtual height estimates', () => {
)
})
it('accounts for the response separator when assistant details are visible', () => {
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' }
expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe(
estimatedMsgHeight(msg, 80, { compact: false, details: false }) + 3
)
})
it('does not account for a response separator without visible details', () => {
const msg: Msg = { role: 'assistant', text: 'ok' }
expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe(
estimatedMsgHeight(msg, 80, { compact: false, details: false })
)
})
it('honors per-section visibility when estimating response separators', () => {
const thinkingOnly: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' }
const toolsOnly: Msg = { role: 'assistant', text: 'ok', tools: ['Tool A'] }
expect(
estimatedMsgHeight(thinkingOnly, 80, {
compact: false,
details: true,
thinkingVisible: false,
toolsVisible: true
})
).toBe(estimatedMsgHeight(thinkingOnly, 80, { compact: false, details: false }))
expect(
estimatedMsgHeight(toolsOnly, 80, {
compact: false,
details: true,
thinkingVisible: true,
toolsVisible: false
})
).toBe(estimatedMsgHeight(toolsOnly, 80, { compact: false, details: false }))
})
it('reserves two extra rows for the inter-turn separator on non-first user messages', () => {
const msg: Msg = { role: 'user', text: 'follow-up question' }
const base = estimatedMsgHeight(msg, 80, { compact: false, details: false })

View File

@@ -252,7 +252,10 @@ export function useMainApp(gw: GatewayClient) {
return `${thinking}:${tools}`
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
const [thinkingDetailsMode, toolsDetailsMode] = detailsLayoutKey.split(':')
const thinkingDetailsVisible = thinkingDetailsMode !== 'hidden'
const toolsDetailsVisible = toolsDetailsMode !== 'hidden'
const detailsVisible = thinkingDetailsVisible || toolsDetailsVisible
const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt)
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
@@ -281,10 +284,21 @@ export function useMainApp(gw: GatewayClient) {
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
compact: ui.compact,
details: detailsVisible,
thinkingVisible: thinkingDetailsVisible,
toolsVisible: toolsDetailsVisible,
userPrompt: ui.theme.brand.prompt,
withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx
}),
[cols, detailsVisible, firstUserIdx, ui.compact, ui.theme.brand.prompt, virtualRows]
[
cols,
detailsVisible,
firstUserIdx,
thinkingDetailsVisible,
toolsDetailsVisible,
ui.compact,
ui.theme.brand.prompt,
virtualRows
]
)
const syncHeightCache = useCallback(

View File

@@ -109,6 +109,8 @@ export const MessageLine = memo(function MessageLine({
const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
const showResponseSeparator = shouldShowResponseSeparator(msg, showDetails)
const content = (() => {
if (msg.kind === 'slash') {
return <Text color={t.color.muted}>{msg.text}</Text>
@@ -195,6 +197,17 @@ export const MessageLine = memo(function MessageLine({
</Box>
)}
{showResponseSeparator && (
<Box marginBottom={1}>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text color={t.color.border}> </Text>
</NoSelect>
<Text color={t.color.muted} dim>
Response
</Text>
</Box>
)}
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text bold={msg.role === 'user'} color={prefix}>
@@ -208,6 +221,9 @@ export const MessageLine = memo(function MessageLine({
)
})
export const shouldShowResponseSeparator = (msg: Msg, showDetails: boolean): boolean =>
msg.role === 'assistant' && showDetails && /\S/.test(msg.text)
interface MessageLineProps {
cols: number
compact?: boolean

View File

@@ -1,6 +1,6 @@
import { TERMUX_TUI_MODE } from '../config/env.js'
import type { Msg } from '../types.js'
import { TERMUX_TUI_MODE } from '../config/env.js'
import { transcriptBodyWidth } from './inputMetrics.js'
const hashText = (text: string) => {
@@ -72,11 +72,15 @@ export const estimatedMsgHeight = (
{
compact,
details,
thinkingVisible = details,
toolsVisible = details,
userPrompt = '',
withSeparator = false
}: {
compact: boolean
details: boolean
thinkingVisible?: boolean
toolsVisible?: boolean
userPrompt?: string
withSeparator?: boolean
}
@@ -111,7 +115,17 @@ export const estimatedMsgHeight = (
}
if (details) {
h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth)
const hasVisibleTools = toolsVisible && Boolean(msg.tools?.length)
const hasVisibleThinking = thinkingVisible && /\S/.test(msg.thinking ?? '')
const hasVisibleDetails = hasVisibleTools || hasVisibleThinking
if (hasVisibleDetails) {
h += (hasVisibleTools ? (msg.tools?.length ?? 0) : 0) + (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0)
if (msg.role === 'assistant' && /\S/.test(msg.text)) {
h += 2
}
}
}
if (msg.role === 'user' || msg.kind === 'diff') {