fix: read alternate-screen agent history
This commit is contained in:
parent
7db744ab47
commit
bc1c052d15
|
|
@ -81,13 +81,13 @@ Pane input addresses the terminal regardless of its current occupant. Agent inpu
|
|||
|
||||
At the CLI, both `pane read` and `agent read` print terminal text directly. The default is UTF-8 text with ANSI escapes stripped; use `--format ansi` or `--ansi` to preserve terminal escapes where the source exposes them. The `detection` source is always plain text. For recent sources, `--lines N` selects the last N rendered terminal rows before optional unwrapping; without it, reads default to 80 rows. For `visible` and `detection`, omitting `--lines` returns the full snapshot, while specifying it keeps the last N newline-delimited lines. The socket API returns the text at `.result.read.text`.
|
||||
|
||||
## Known caveat: alternate-screen output
|
||||
## Alternate-screen history reads
|
||||
|
||||
Full-screen agents such as Claude Code and OpenCode may render in the terminal's alternate screen. Alternate-screen rows do not enter Herdr's host scrollback. `--lines` can only request more rows from the pane's available screen and host scrollback; it cannot create missing history. If increasing `--lines` returns no additional response text, the pane is probably using the alternate screen and those response rows are no longer retained. A larger font or smaller pane makes this more likely.
|
||||
Full-screen agents such as Claude Code and OpenCode render transcript history in the terminal's alternate screen instead of Herdr's host scrollback. For an idle, recognized agent at the bottom of its transcript, text reads from `recent` or `recent-unwrapped` automatically use the agent's mouse-scroll interface when `--lines` requests more than the visible screen. Herdr collects overlapping pages and returns the viewport to the bottom before completing the read. The same behavior applies to `pane read` when the pane contains that agent; it requires no additional option.
|
||||
|
||||
Ask the agent for concise responses, enlarge the pane or use a smaller font, or use the agent's own transcript and scrolling controls. After scrolling inside the agent, use `--source visible` to read the page it currently renders.
|
||||
Other reads remain passive. Herdr does not move the application viewport for `visible`, `detection`, or ANSI reads, output waits and subscriptions, a manually scrolled agent, a direct attachment, or an application that does not report mouse-wheel input. An explicit `agent read --lines N` that needs alternate-screen history returns `agent_not_idle` while the agent is working, blocked, or unknown; wait for idle and retry, or use `--source visible`. Other recent reads return the available screen and host scrollback as before.
|
||||
|
||||
After that failed read, ask the agent to write its complete response as Markdown in a temporary directory and reply only with the file path, then read the file directly. Use this only as a fallback; do not request file output in the initial prompt.
|
||||
If a full response is still unavailable, ask the agent to write it as Markdown in a temporary directory and reply only with the file path, then read the file directly.
|
||||
|
||||
Successful `agent start`, `agent prompt`, and `agent wait` commands return the current agent at `.result.agent`. `pane wait-output` returns `.result.pane_id`, `.result.matched_line`, and the matched snapshot at `.result.read`.
|
||||
|
||||
|
|
|
|||
|
|
@ -81,13 +81,13 @@ herdr agent rename w1:p2 reviewer
|
|||
|
||||
CLI では、`pane read` と `agent read` のどちらもターミナルテキストを直接出力します。デフォルトは ANSI エスケープを除去した UTF-8 テキストです。ソースに装飾が含まれる場合、`--format ansi` または `--ansi` でエスケープを残します。`detection` ソースは常にプレーンテキストです。recent 系ソースでは、`--lines N` は折り返し解除の前に末尾の N 描画行を選び、省略時は 80 行です。`visible` と `detection` では、省略時にスナップショット全体を返し、指定時は改行で区切られた末尾 N 行を返します。socket API ではテキストを `.result.read.text` に返します。
|
||||
|
||||
## 既知の注意点: 代替画面の出力
|
||||
## 代替画面の履歴読み取り
|
||||
|
||||
Claude Code や OpenCode などの全画面エージェントは、ターミナルの代替画面に描画することがあります。代替画面の行は Herdr のホスト側スクロールバックには入りません。`--lines` で取得できるのは、ペインの現在の画面とホスト側スクロールバックに存在する行だけであり、失われた履歴を作り出すことはできません。`--lines` を増やしても応答テキストが増えない場合、そのペインは代替画面を使っている可能性が高く、応答の行はすでに保持されていません。フォントが大きい場合やペインが小さい場合は、より起こりやすくなります。
|
||||
Claude Code や OpenCode などの全画面エージェントは、トランスクリプト履歴を Herdr のホスト側スクロールバックではなく、ターミナルの代替画面に描画します。認識済みのエージェントが idle でトランスクリプトの末尾にあり、`--lines` が表示画面より多い行を要求した場合、`recent` または `recent-unwrapped` のテキスト読み取りはエージェントのマウススクロール操作を自動的に使います。Herdr は重なり合うページを収集し、読み取りが完了する前にビューポートを末尾へ戻します。そのペインに対象エージェントがいる場合、`pane read` にも同じ動作が追加オプションなしで適用されます。
|
||||
|
||||
エージェントに簡潔な応答を求めるか、ペインを広げる、フォントを小さくする、またはエージェント自身のトランスクリプトやスクロール操作を使ってください。エージェント内でスクロールした後、`--source visible` を使うと、現在描画されているページを読み取れます。
|
||||
その他の読み取りは受動的なままです。`visible`、`detection`、ANSI 読み取り、出力待機と購読、手動でスクロールされたエージェント、直接アタッチ中のペイン、マウスホイール入力を報告しないアプリケーションでは、Herdr はアプリケーションのビューポートを動かしません。明示的な `agent read --lines N` が代替画面の履歴を必要とする場合、エージェントが working、blocked、または unknown の間は `agent_not_idle` を返します。idle になるまで待って再試行するか、`--source visible` を使ってください。その他の recent 読み取りは従来どおり、利用可能な画面とホスト側スクロールバックを返します。
|
||||
|
||||
その読み取りに失敗した後、エージェントに完全な応答を一時ディレクトリ内へ Markdown 形式で書き込み、ファイルパスだけを返すよう依頼してから、そのファイルを直接読み取ってください。これはフォールバックとしてのみ使い、最初のプロンプトではファイル出力を求めないでください。
|
||||
完全な応答を取得できない場合は、一時ディレクトリの Markdown ファイルに応答を書き込み、ファイルパスだけを返すようエージェントに依頼して、そのファイルを直接読み取ってください。
|
||||
|
||||
成功した `agent start`、`agent prompt`、`agent wait` は現在のエージェントを `.result.agent` に返します。`pane wait-output` は `.result.pane_id`、`.result.matched_line`、一致したスナップショット `.result.read` を返します。
|
||||
|
||||
|
|
|
|||
|
|
@ -81,13 +81,13 @@ herdr agent rename w1:p2 reviewer
|
|||
|
||||
在 CLI 中,`pane read` 和 `agent read` 都直接打印终端文本。默认输出去除 ANSI 转义的 UTF-8 文本;来源包含样式时,使用 `--format ansi` 或 `--ansi` 保留终端转义。`detection` 来源始终是纯文本。对 recent 类来源,`--lines N` 会在可选的取消折行之前选择最后 N 个已渲染终端行;省略时默认读取 80 行。对 `visible` 和 `detection`,省略 `--lines` 会返回完整快照,指定时保留按换行分隔的最后 N 行。socket API 在 `.result.read.text` 返回文本。
|
||||
|
||||
## 已知注意事项:备用屏幕输出
|
||||
## 备用屏幕历史读取
|
||||
|
||||
Claude Code 和 OpenCode 等全屏智能体可能会在终端的备用屏幕中绘制。备用屏幕中的行不会进入 Herdr 的主机回滚缓冲区。`--lines` 只能请求窗格当前屏幕和主机回滚缓冲区中现有的更多行,不能生成缺失的历史。如果增大 `--lines` 后仍没有返回更多回复文本,该窗格很可能正在使用备用屏幕,且那些回复行已不再保留。字体较大或窗格较小时更容易遇到此限制。
|
||||
Claude Code 和 OpenCode 等全屏智能体会把对话历史绘制在终端的备用屏幕中,而不是写入 Herdr 的主机回滚缓冲区。当已识别的智能体处于 idle、对话位于底部,且 `--lines` 请求的行数超过可见屏幕时,`recent` 或 `recent-unwrapped` 文本读取会自动使用智能体的鼠标滚动界面。Herdr 会收集相互重叠的页面,并在完成读取前把视口恢复到底部。当窗格中包含该智能体时,`pane read` 也会自动采用相同行为,无需额外选项。
|
||||
|
||||
可以要求智能体简洁回复、扩大窗格或使用较小字体,也可以使用智能体自身的记录和滚动控件。在智能体内部滚动后,使用 `--source visible` 读取它当前绘制的页面。
|
||||
其他读取仍保持被动。对于 `visible`、`detection` 或 ANSI 读取、输出等待和订阅、已被手动滚动的智能体、直接附加的窗格,以及不报告鼠标滚轮输入的应用,Herdr 不会移动应用视口。当显式的 `agent read --lines N` 需要备用屏幕历史时,如果智能体处于 working、blocked 或 unknown,命令会返回 `agent_not_idle`;请等待 idle 后重试,或使用 `--source visible`。其他 recent 读取仍像以前一样返回可用的屏幕和主机回滚内容。
|
||||
|
||||
在这次读取失败后,可以让智能体把完整回复以 Markdown 格式写入临时目录,只回复文件路径,然后直接读取该文件。仅将此方法用作后备方案;不要在初始提示中要求文件输出。
|
||||
如果仍无法取得完整回复,可以让智能体把回复以 Markdown 格式写入临时目录,只回复文件路径,然后直接读取该文件。
|
||||
|
||||
成功的 `agent start`、`agent prompt` 和 `agent wait` 会在 `.result.agent` 返回当前智能体。`pane wait-output` 返回 `.result.pane_id`、`.result.matched_line` 以及位于 `.result.read` 的匹配快照。
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ pub enum ReadSource {
|
|||
Detection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) enum ReadIntent {
|
||||
#[default]
|
||||
Interactive,
|
||||
Passive,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default,
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -258,6 +258,9 @@ pub struct PaneReadParams {
|
|||
pub format: ReadFormat,
|
||||
#[serde(default = "super::default_true")]
|
||||
pub strip_ansi: bool,
|
||||
#[serde(skip)]
|
||||
#[schemars(skip)]
|
||||
pub(crate) intent: super::common::ReadIntent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
|
|
|
|||
|
|
@ -412,10 +412,13 @@ fn pane_read_defaults_to_text_format() {
|
|||
"#;
|
||||
|
||||
let request: Request = serde_json::from_str(json).unwrap();
|
||||
let serialized = serde_json::to_value(&request).unwrap();
|
||||
assert!(serialized["params"].get("intent").is_none());
|
||||
let Method::PaneRead(params) = request.method else {
|
||||
panic!("wrong method parsed");
|
||||
};
|
||||
assert_eq!(params.format, ReadFormat::Text);
|
||||
assert_eq!(params.intent, ReadIntent::Interactive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -557,6 +557,7 @@ fn pane_read(
|
|||
lines,
|
||||
format: crate::api::schema::ReadFormat::Text,
|
||||
strip_ansi,
|
||||
intent: crate::api::schema::ReadIntent::Passive,
|
||||
}),
|
||||
},
|
||||
api_tx,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ pub(super) fn wait_for_output(
|
|||
lines: params.lines,
|
||||
format: crate::api::schema::ReadFormat::Text,
|
||||
strip_ansi: params.strip_ansi,
|
||||
intent: crate::api::schema::ReadIntent::Passive,
|
||||
}),
|
||||
};
|
||||
let response =
|
||||
|
|
|
|||
|
|
@ -2013,6 +2013,7 @@ mod tests {
|
|||
lines: Some(2),
|
||||
format: crate::api::schema::ReadFormat::Text,
|
||||
strip_ansi: true,
|
||||
intent: crate::api::schema::ReadIntent::Interactive,
|
||||
},
|
||||
);
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
|
|
|||
|
|
@ -143,7 +143,10 @@ pub(super) fn read_terminal_snapshot(
|
|||
}
|
||||
}
|
||||
|
||||
fn limit_snapshot_lines(text: String, limit: Option<usize>) -> crate::pane::TerminalReadSnapshot {
|
||||
pub(crate) fn limit_snapshot_lines(
|
||||
text: String,
|
||||
limit: Option<usize>,
|
||||
) -> crate::pane::TerminalReadSnapshot {
|
||||
let Some(limit) = limit else {
|
||||
return crate::pane::TerminalReadSnapshot {
|
||||
text,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub(crate) mod agent_view;
|
|||
mod agents;
|
||||
mod api;
|
||||
mod api_helpers;
|
||||
pub(crate) use api_helpers::limit_snapshot_lines;
|
||||
mod config_io;
|
||||
mod creation;
|
||||
mod git_refresh;
|
||||
|
|
|
|||
|
|
@ -503,6 +503,7 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> {
|
|||
lines,
|
||||
format,
|
||||
strip_ansi,
|
||||
intent: crate::api::schema::ReadIntent::Interactive,
|
||||
}),
|
||||
})?;
|
||||
|
||||
|
|
|
|||
10
src/pane.rs
10
src/pane.rs
|
|
@ -2711,6 +2711,16 @@ impl PaneRuntime {
|
|||
self.terminal.wheel_routing()
|
||||
}
|
||||
|
||||
pub(crate) fn screen_text_snapshot(
|
||||
&self,
|
||||
) -> Option<(
|
||||
crate::ghostty::ActiveScreen,
|
||||
u16,
|
||||
Vec<crate::ghostty::ScreenTextRow>,
|
||||
)> {
|
||||
self.terminal.screen_text_snapshot()
|
||||
}
|
||||
|
||||
pub fn encode_mouse_button(
|
||||
&self,
|
||||
kind: crossterm::event::MouseEventKind,
|
||||
|
|
|
|||
|
|
@ -369,6 +369,16 @@ impl PaneTerminal {
|
|||
self.ghostty.wheel_routing()
|
||||
}
|
||||
|
||||
pub(crate) fn screen_text_snapshot(
|
||||
&self,
|
||||
) -> Option<(
|
||||
crate::ghostty::ActiveScreen,
|
||||
u16,
|
||||
Vec<crate::ghostty::ScreenTextRow>,
|
||||
)> {
|
||||
self.ghostty.screen_text_snapshot()
|
||||
}
|
||||
|
||||
pub fn cursor_state(&self) -> Option<TerminalCursorState> {
|
||||
self.ghostty.cursor_state()
|
||||
}
|
||||
|
|
@ -1714,6 +1724,21 @@ impl GhosttyPaneTerminal {
|
|||
.filter(|bytes| !bytes.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn screen_text_snapshot(
|
||||
&self,
|
||||
) -> Option<(
|
||||
crate::ghostty::ActiveScreen,
|
||||
u16,
|
||||
Vec<crate::ghostty::ScreenTextRow>,
|
||||
)> {
|
||||
let core = self.core.lock().ok()?;
|
||||
Some((
|
||||
core.terminal.active_screen().ok()?,
|
||||
core.terminal.cols().ok()?,
|
||||
core.terminal.screen_text_rows().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn visible_text(&self) -> String {
|
||||
self.core
|
||||
.lock()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,383 @@
|
|||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use crossterm::event::{KeyModifiers, MouseEventKind};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::api::schema::{PaneReadResult, ResponseResult, SuccessResponse};
|
||||
use crate::terminal::{ScreenSnapshot, TerminalId, TerminalRuntime, UpwardMerge};
|
||||
|
||||
const STEP_SETTLE: Duration = Duration::from_millis(120);
|
||||
const MAX_DURATION: Duration = Duration::from_secs(15);
|
||||
const MAX_RESTORE_DURATION: Duration = Duration::from_secs(5);
|
||||
const MAX_UNALIGNED_CHECKS: u8 = 4;
|
||||
const WHEEL_STEP_EVENTS: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
SettleInitial { checks: u8 },
|
||||
ProbeBottom,
|
||||
RestoreProbe,
|
||||
Harvest { unaligned_checks: u8 },
|
||||
Restore { stable_checks: u8 },
|
||||
}
|
||||
|
||||
pub(crate) struct PendingAltScreenRead {
|
||||
pub(crate) terminal_id: TerminalId,
|
||||
request_id: String,
|
||||
respond_to: mpsc::Sender<String>,
|
||||
fallback_response: String,
|
||||
read: PaneReadResult,
|
||||
lines: usize,
|
||||
unwrap: bool,
|
||||
initial: ScreenSnapshot,
|
||||
previous: ScreenSnapshot,
|
||||
history: Vec<crate::ghostty::ScreenTextRow>,
|
||||
phase: Phase,
|
||||
next_poll_at: Instant,
|
||||
started_at: Instant,
|
||||
restore_started_at: Option<Instant>,
|
||||
upward_events: usize,
|
||||
reached_top: bool,
|
||||
valid: bool,
|
||||
}
|
||||
|
||||
impl PendingAltScreenRead {
|
||||
pub(crate) fn start(
|
||||
terminal_id: TerminalId,
|
||||
request_id: String,
|
||||
respond_to: mpsc::Sender<String>,
|
||||
fallback_response: String,
|
||||
read: PaneReadResult,
|
||||
lines: usize,
|
||||
unwrap: bool,
|
||||
initial: ScreenSnapshot,
|
||||
now: Instant,
|
||||
) -> Self {
|
||||
Self {
|
||||
terminal_id,
|
||||
request_id,
|
||||
respond_to,
|
||||
fallback_response,
|
||||
read,
|
||||
lines,
|
||||
unwrap,
|
||||
previous: initial.clone(),
|
||||
history: initial.rows.clone(),
|
||||
initial,
|
||||
phase: Phase::SettleInitial { checks: 0 },
|
||||
next_poll_at: now + STEP_SETTLE,
|
||||
started_at: now,
|
||||
restore_started_at: None,
|
||||
upward_events: 0,
|
||||
reached_top: false,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn next_deadline(&self) -> Instant {
|
||||
self.next_poll_at
|
||||
}
|
||||
|
||||
pub(crate) fn frozen_snapshot(
|
||||
&self,
|
||||
source: crate::api::schema::ReadSource,
|
||||
lines: Option<u32>,
|
||||
) -> crate::pane::TerminalReadSnapshot {
|
||||
let line_limit = lines.map(|lines| lines.min(1000) as usize);
|
||||
match source {
|
||||
crate::api::schema::ReadSource::Recent
|
||||
| crate::api::schema::ReadSource::RecentUnwrapped => {
|
||||
let limit = line_limit.unwrap_or(80);
|
||||
crate::terminal::snapshot_text(
|
||||
&self.initial.rows,
|
||||
limit,
|
||||
source == crate::api::schema::ReadSource::RecentUnwrapped,
|
||||
self.initial.rows.len() > limit,
|
||||
)
|
||||
}
|
||||
crate::api::schema::ReadSource::Visible | crate::api::schema::ReadSource::Detection => {
|
||||
let snapshot = crate::terminal::snapshot_text(
|
||||
&self.initial.rows,
|
||||
self.initial.rows.len(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
crate::app::limit_snapshot_lines(snapshot.text, line_limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn abort(mut self, runtime: Option<&TerminalRuntime>, now: Instant) -> PollOutcome {
|
||||
self.valid = false;
|
||||
match self.phase {
|
||||
Phase::SettleInitial { .. } => self.complete_fallback(),
|
||||
Phase::Harvest { .. } => match runtime {
|
||||
Some(runtime) => self.start_restore(runtime, now),
|
||||
None => self.complete_fallback(),
|
||||
},
|
||||
Phase::ProbeBottom | Phase::RestoreProbe | Phase::Restore { .. } => {
|
||||
self.poll(runtime, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn poll(mut self, runtime: Option<&TerminalRuntime>, now: Instant) -> PollOutcome {
|
||||
if now < self.next_poll_at {
|
||||
return Some(self);
|
||||
}
|
||||
let Some(runtime) = runtime else {
|
||||
return self.complete_fallback();
|
||||
};
|
||||
let Some((screen, snapshot)) = runtime.screen_text_snapshot() else {
|
||||
return self.complete_fallback();
|
||||
};
|
||||
if screen != crate::ghostty::ActiveScreen::Alternate
|
||||
|| snapshot.cols != self.initial.cols
|
||||
|| snapshot.rows.len() != self.initial.rows.len()
|
||||
{
|
||||
return self.complete_fallback();
|
||||
}
|
||||
if self
|
||||
.restore_started_at
|
||||
.is_some_and(|started| now.duration_since(started) >= MAX_RESTORE_DURATION)
|
||||
{
|
||||
return self.complete_fallback();
|
||||
}
|
||||
if now.duration_since(self.started_at) >= MAX_DURATION
|
||||
&& !matches!(
|
||||
self.phase,
|
||||
Phase::ProbeBottom | Phase::Restore { .. } | Phase::RestoreProbe
|
||||
)
|
||||
{
|
||||
self.valid = false;
|
||||
return self.start_restore(runtime, now);
|
||||
}
|
||||
|
||||
match self.phase {
|
||||
Phase::SettleInitial { checks } => {
|
||||
if snapshot.similar_text(&self.initial) {
|
||||
if checks >= 1 {
|
||||
if send_wheel(
|
||||
runtime,
|
||||
MouseEventKind::ScrollDown,
|
||||
WHEEL_STEP_EVENTS,
|
||||
&snapshot,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return self.complete_fallback();
|
||||
}
|
||||
self.phase = Phase::ProbeBottom;
|
||||
} else {
|
||||
self.phase = Phase::SettleInitial { checks: checks + 1 };
|
||||
}
|
||||
} else {
|
||||
self.initial = snapshot.clone();
|
||||
self.previous = snapshot.clone();
|
||||
self.history = snapshot.rows;
|
||||
self.phase = Phase::SettleInitial { checks: 0 };
|
||||
}
|
||||
self.next_poll_at = now + STEP_SETTLE;
|
||||
Some(self)
|
||||
}
|
||||
Phase::ProbeBottom => {
|
||||
debug!(
|
||||
terminal_id = %self.terminal_id,
|
||||
at_bottom = snapshot.similar_text(&self.initial),
|
||||
"alternate-screen read bottom probe settled"
|
||||
);
|
||||
if snapshot.similar_text(&self.initial) {
|
||||
if self.valid {
|
||||
self.start_harvest(runtime, now)
|
||||
} else {
|
||||
self.complete_fallback()
|
||||
}
|
||||
} else if send_wheel(
|
||||
runtime,
|
||||
MouseEventKind::ScrollUp,
|
||||
WHEEL_STEP_EVENTS,
|
||||
&snapshot,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
self.phase = Phase::RestoreProbe;
|
||||
self.restore_started_at = Some(now);
|
||||
self.next_poll_at = now + STEP_SETTLE;
|
||||
Some(self)
|
||||
} else {
|
||||
self.complete_fallback()
|
||||
}
|
||||
}
|
||||
Phase::RestoreProbe => {
|
||||
if snapshot.similar_text(&self.initial) {
|
||||
self.complete_fallback()
|
||||
} else {
|
||||
self.next_poll_at = now + STEP_SETTLE;
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
Phase::Harvest { unaligned_checks } => {
|
||||
let merge = crate::terminal::merge_scrolled_up(
|
||||
&mut self.history,
|
||||
&self.previous,
|
||||
&snapshot,
|
||||
);
|
||||
match merge {
|
||||
UpwardMerge::Advanced { .. } => {
|
||||
self.previous = snapshot;
|
||||
if self.history.len() >= self.lines {
|
||||
self.start_restore(runtime, now)
|
||||
} else {
|
||||
self.start_harvest(runtime, now)
|
||||
}
|
||||
}
|
||||
UpwardMerge::Unchanged => {
|
||||
self.reached_top = true;
|
||||
self.start_restore(runtime, now)
|
||||
}
|
||||
UpwardMerge::Unaligned if unaligned_checks + 1 < MAX_UNALIGNED_CHECKS => {
|
||||
self.phase = Phase::Harvest {
|
||||
unaligned_checks: unaligned_checks + 1,
|
||||
};
|
||||
self.next_poll_at = now + STEP_SETTLE;
|
||||
Some(self)
|
||||
}
|
||||
UpwardMerge::Unaligned => {
|
||||
self.valid = false;
|
||||
self.start_restore(runtime, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
Phase::Restore { stable_checks } => {
|
||||
if snapshot.similar_text(&self.previous) {
|
||||
if stable_checks >= 1 {
|
||||
if self.valid {
|
||||
self.complete_success()
|
||||
} else {
|
||||
self.complete_fallback()
|
||||
}
|
||||
} else {
|
||||
self.phase = Phase::Restore {
|
||||
stable_checks: stable_checks + 1,
|
||||
};
|
||||
self.next_poll_at = now + STEP_SETTLE;
|
||||
Some(self)
|
||||
}
|
||||
} else {
|
||||
self.previous = snapshot;
|
||||
if send_wheel(
|
||||
runtime,
|
||||
MouseEventKind::ScrollDown,
|
||||
restore_batch_size(&self.previous),
|
||||
&self.previous,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
self.phase = Phase::Restore { stable_checks: 0 };
|
||||
self.next_poll_at = now + STEP_SETTLE;
|
||||
Some(self)
|
||||
} else {
|
||||
self.complete_fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_harvest(mut self, runtime: &TerminalRuntime, now: Instant) -> PollOutcome {
|
||||
let events = WHEEL_STEP_EVENTS;
|
||||
if send_wheel(runtime, MouseEventKind::ScrollUp, events, &self.previous).is_err() {
|
||||
return self.complete_fallback();
|
||||
}
|
||||
self.upward_events = self.upward_events.saturating_add(events);
|
||||
self.phase = Phase::Harvest {
|
||||
unaligned_checks: 0,
|
||||
};
|
||||
self.next_poll_at = now + STEP_SETTLE;
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn start_restore(mut self, runtime: &TerminalRuntime, now: Instant) -> PollOutcome {
|
||||
if self.upward_events == 0 {
|
||||
return self.complete_fallback();
|
||||
}
|
||||
if send_wheel(
|
||||
runtime,
|
||||
MouseEventKind::ScrollDown,
|
||||
self.upward_events,
|
||||
&self.previous,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return self.complete_fallback();
|
||||
}
|
||||
self.phase = Phase::Restore { stable_checks: 0 };
|
||||
self.restore_started_at = Some(now);
|
||||
self.next_poll_at = now + STEP_SETTLE;
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn complete_success(mut self) -> PollOutcome {
|
||||
debug!(
|
||||
terminal_id = %self.terminal_id,
|
||||
retained_rows = self.history.len(),
|
||||
requested_rows = self.lines,
|
||||
reached_top = self.reached_top,
|
||||
"alternate-screen read completed"
|
||||
);
|
||||
let truncated = !self.reached_top || self.history.len() > self.lines;
|
||||
let snapshot =
|
||||
crate::terminal::snapshot_text(&self.history, self.lines, self.unwrap, truncated);
|
||||
self.read.text = snapshot.text;
|
||||
self.read.truncated = snapshot.truncated;
|
||||
let response = serde_json::to_string(&SuccessResponse {
|
||||
id: self.request_id,
|
||||
result: ResponseResult::PaneRead { read: self.read },
|
||||
})
|
||||
.unwrap_or(self.fallback_response);
|
||||
let _ = self.respond_to.send(response);
|
||||
None
|
||||
}
|
||||
|
||||
fn complete_fallback(self) -> PollOutcome {
|
||||
debug!(
|
||||
terminal_id = %self.terminal_id,
|
||||
?self.phase,
|
||||
retained_rows = self.history.len(),
|
||||
upward_events = self.upward_events,
|
||||
valid = self.valid,
|
||||
"alternate-screen read fell back to passive snapshot"
|
||||
);
|
||||
let _ = self.respond_to.send(self.fallback_response);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type PollOutcome = Option<PendingAltScreenRead>;
|
||||
|
||||
fn restore_batch_size(snapshot: &ScreenSnapshot) -> usize {
|
||||
snapshot.rows.len().saturating_div(2).max(1)
|
||||
}
|
||||
|
||||
fn send_wheel(
|
||||
runtime: &TerminalRuntime,
|
||||
kind: MouseEventKind,
|
||||
events: usize,
|
||||
snapshot: &ScreenSnapshot,
|
||||
) -> Result<(), ()> {
|
||||
if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) {
|
||||
return Err(());
|
||||
}
|
||||
let column = snapshot.cols.saturating_sub(1) / 2;
|
||||
let row = u16::try_from(snapshot.rows.len().saturating_sub(1) / 2).unwrap_or(0);
|
||||
let event = runtime
|
||||
.encode_mouse_wheel(kind, column, row, KeyModifiers::empty())
|
||||
.ok_or(())?;
|
||||
let mut bytes = Vec::with_capacity(event.len().saturating_mul(events));
|
||||
for _ in 0..events {
|
||||
bytes.extend_from_slice(&event);
|
||||
}
|
||||
runtime.try_send_bytes(Bytes::from(bytes)).map_err(|_| ())
|
||||
}
|
||||
|
|
@ -275,6 +275,19 @@ const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
|||
// Headless server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct AltScreenReadSpec {
|
||||
terminal_id: crate::terminal::TerminalId,
|
||||
lines: usize,
|
||||
unwrap: bool,
|
||||
initial: crate::terminal::ScreenSnapshot,
|
||||
}
|
||||
|
||||
enum AltScreenReadConflict {
|
||||
None,
|
||||
Frozen(crate::pane::TerminalReadSnapshot),
|
||||
Defer,
|
||||
}
|
||||
|
||||
/// The headless server — runs the herdr event loop without a real terminal.
|
||||
pub struct HeadlessServer {
|
||||
app: app::App,
|
||||
|
|
@ -300,6 +313,10 @@ pub struct HeadlessServer {
|
|||
server_config_diagnostic_without_keybindings: Option<String>,
|
||||
/// Writable direct attach owner per terminal id string.
|
||||
terminal_attach_owners: HashMap<String, u64>,
|
||||
/// Deferred application-history reads currently driving alternate-screen viewports.
|
||||
pending_alt_screen_reads: Vec<crate::server::alt_screen_read::PendingAltScreenRead>,
|
||||
/// Reads waiting for an alternate-screen traversal of the same terminal to finish.
|
||||
deferred_alt_screen_reads: Vec<api::ApiRequestMessage>,
|
||||
/// Monotonic activity counter used to pick the most recently active client.
|
||||
next_activity_stamp: u64,
|
||||
/// Shared pane runtime size derived from the foreground client,
|
||||
|
|
@ -492,6 +509,8 @@ impl HeadlessServer {
|
|||
server_config_diagnostic,
|
||||
server_config_diagnostic_without_keybindings,
|
||||
terminal_attach_owners: HashMap::new(),
|
||||
pending_alt_screen_reads: Vec::new(),
|
||||
deferred_alt_screen_reads: Vec::new(),
|
||||
next_activity_stamp: 1,
|
||||
effective_size: (MIN_COLS, MIN_ROWS),
|
||||
shutting_down: false,
|
||||
|
|
@ -638,6 +657,13 @@ impl HeadlessServer {
|
|||
needs_graphics_render = false;
|
||||
}
|
||||
|
||||
self.poll_pending_alt_screen_reads(now);
|
||||
if self.process_deferred_alt_screen_reads() {
|
||||
needs_render = true;
|
||||
needs_full_render = true;
|
||||
needs_graphics_render = false;
|
||||
}
|
||||
|
||||
if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() {
|
||||
needs_render = true;
|
||||
needs_full_render = true;
|
||||
|
|
@ -728,6 +754,13 @@ impl HeadlessServer {
|
|||
)
|
||||
.map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL))
|
||||
.or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL));
|
||||
let next_deadline = self
|
||||
.pending_alt_screen_reads
|
||||
.iter()
|
||||
.map(|pending| pending.next_deadline())
|
||||
.fold(next_deadline, |deadline, pending| {
|
||||
Some(deadline.map_or(pending, |current| current.min(pending)))
|
||||
});
|
||||
let event = {
|
||||
tokio::select! {
|
||||
maybe_api = self.app.api_rx.recv() => match maybe_api {
|
||||
|
|
@ -2563,6 +2596,23 @@ impl HeadlessServer {
|
|||
return false;
|
||||
};
|
||||
|
||||
if self
|
||||
.pending_alt_screen_reads
|
||||
.iter()
|
||||
.any(|pending| pending.terminal_id == real_terminal_id)
|
||||
{
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some(format!(
|
||||
"terminal attach failed: terminal {terminal_id} has a read in progress; retry"
|
||||
)),
|
||||
},
|
||||
);
|
||||
self.remove_client_and_resize_if_needed(client_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(existing_owner) = self.terminal_attach_owners.get(&terminal_id).copied() {
|
||||
if existing_owner != client_id && !takeover {
|
||||
self.send_to_client(
|
||||
|
|
@ -3021,6 +3071,188 @@ impl HeadlessServer {
|
|||
)
|
||||
}
|
||||
|
||||
fn agent_read_not_idle_error(
|
||||
&self,
|
||||
request: &api::schema::Request,
|
||||
) -> Option<api::schema::ErrorBody> {
|
||||
use api::schema::{Method, ReadFormat, ReadSource};
|
||||
|
||||
let Method::AgentRead(params) = &request.method else {
|
||||
return None;
|
||||
};
|
||||
let requested = params.lines?;
|
||||
if params.format != ReadFormat::Text
|
||||
|| !matches!(
|
||||
params.source,
|
||||
ReadSource::Recent | ReadSource::RecentUnwrapped
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let target = self.app.resolve_agent_target(¶ms.target).ok()?;
|
||||
let terminal = self
|
||||
.app
|
||||
.state
|
||||
.terminals
|
||||
.values()
|
||||
.find(|terminal| terminal.id.as_str() == target.terminal_id)?;
|
||||
if terminal.effective_known_agent().is_none()
|
||||
|| terminal.state == crate::detect::AgentState::Idle
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let runtime = self.app.terminal_runtimes.get(&terminal.id)?;
|
||||
let (screen, snapshot) = runtime.screen_text_snapshot()?;
|
||||
if screen != crate::ghostty::ActiveScreen::Alternate
|
||||
|| snapshot.rows.len() >= requested.min(1000) as usize
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let status = crate::detect::manifest::agent_state_label(terminal.state);
|
||||
Some(api::schema::ErrorBody {
|
||||
code: "agent_not_idle".into(),
|
||||
message: format!(
|
||||
"cannot read {requested} lines while {} is {status}: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible",
|
||||
params.target
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn alt_screen_read_spec(&self, request: &api::schema::Request) -> Option<AltScreenReadSpec> {
|
||||
use api::schema::{Method, ReadFormat, ReadIntent, ReadSource};
|
||||
|
||||
let (target, source, lines, format) = match &request.method {
|
||||
Method::AgentRead(params) => (
|
||||
self.app.resolve_agent_target(¶ms.target).ok()?,
|
||||
params.source,
|
||||
params.lines,
|
||||
params.format,
|
||||
),
|
||||
Method::PaneRead(params) if params.intent == ReadIntent::Interactive => (
|
||||
self.app.resolve_terminal_target(¶ms.pane_id).ok()?,
|
||||
params.source,
|
||||
params.lines,
|
||||
params.format,
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
if format != ReadFormat::Text
|
||||
|| !matches!(source, ReadSource::Recent | ReadSource::RecentUnwrapped)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let lines = lines.unwrap_or(80).min(1000) as usize;
|
||||
if lines == 0
|
||||
|| self
|
||||
.terminal_attach_owners
|
||||
.contains_key(target.terminal_id.as_str())
|
||||
|| self
|
||||
.pending_alt_screen_reads
|
||||
.iter()
|
||||
.any(|pending| pending.terminal_id.as_str() == target.terminal_id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let terminal = self
|
||||
.app
|
||||
.state
|
||||
.terminals
|
||||
.values()
|
||||
.find(|terminal| terminal.id.as_str() == target.terminal_id)?;
|
||||
if terminal.effective_known_agent().is_none()
|
||||
|| terminal.state != crate::detect::AgentState::Idle
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let runtime = self.app.terminal_runtimes.get(&terminal.id)?;
|
||||
if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) {
|
||||
return None;
|
||||
}
|
||||
let (screen, initial) = runtime.screen_text_snapshot()?;
|
||||
if screen != crate::ghostty::ActiveScreen::Alternate || initial.rows.len() >= lines {
|
||||
return None;
|
||||
}
|
||||
Some(AltScreenReadSpec {
|
||||
terminal_id: terminal.id.clone(),
|
||||
lines,
|
||||
unwrap: source == ReadSource::RecentUnwrapped,
|
||||
initial,
|
||||
})
|
||||
}
|
||||
|
||||
fn poll_pending_alt_screen_reads(&mut self, now: Instant) {
|
||||
let pending = std::mem::take(&mut self.pending_alt_screen_reads);
|
||||
for read in pending {
|
||||
let runtime = self.app.terminal_runtimes.get(&read.terminal_id);
|
||||
let remains_idle = self
|
||||
.app
|
||||
.state
|
||||
.terminals
|
||||
.get(&read.terminal_id)
|
||||
.is_some_and(|terminal| terminal.state == crate::detect::AgentState::Idle);
|
||||
let attached = self
|
||||
.terminal_attach_owners
|
||||
.contains_key(read.terminal_id.as_str());
|
||||
let outcome = if remains_idle && !attached {
|
||||
read.poll(runtime, now)
|
||||
} else {
|
||||
read.abort(runtime, now)
|
||||
};
|
||||
if let Some(read) = outcome {
|
||||
self.pending_alt_screen_reads.push(read);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn alt_screen_read_conflict(&self, request: &api::schema::Request) -> AltScreenReadConflict {
|
||||
let (target, source, lines, format) = match &request.method {
|
||||
api::schema::Method::AgentRead(params) => (
|
||||
self.app.resolve_agent_target(¶ms.target).ok(),
|
||||
params.source,
|
||||
params.lines,
|
||||
params.format,
|
||||
),
|
||||
api::schema::Method::PaneRead(params) => (
|
||||
self.app.resolve_terminal_target(¶ms.pane_id).ok(),
|
||||
params.source,
|
||||
params.lines,
|
||||
params.format,
|
||||
),
|
||||
_ => return AltScreenReadConflict::None,
|
||||
};
|
||||
let Some(target) = target else {
|
||||
return AltScreenReadConflict::None;
|
||||
};
|
||||
let Some(pending) = self
|
||||
.pending_alt_screen_reads
|
||||
.iter()
|
||||
.find(|pending| pending.terminal_id.as_str() == target.terminal_id)
|
||||
else {
|
||||
return AltScreenReadConflict::None;
|
||||
};
|
||||
if format == api::schema::ReadFormat::Text {
|
||||
AltScreenReadConflict::Frozen(pending.frozen_snapshot(source, lines))
|
||||
} else {
|
||||
AltScreenReadConflict::Defer
|
||||
}
|
||||
}
|
||||
|
||||
fn process_deferred_alt_screen_reads(&mut self) -> bool {
|
||||
let deferred = std::mem::take(&mut self.deferred_alt_screen_reads);
|
||||
let mut changed = false;
|
||||
for msg in deferred {
|
||||
match self.alt_screen_read_conflict(&msg.request) {
|
||||
AltScreenReadConflict::None => {
|
||||
changed |= self.handle_api_request_with_shutdown_check(msg);
|
||||
}
|
||||
AltScreenReadConflict::Frozen(_) | AltScreenReadConflict::Defer => {
|
||||
self.deferred_alt_screen_reads.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Drains API requests with shutdown awareness.
|
||||
///
|
||||
/// During shutdown, remaining requests get a `server_unavailable` error.
|
||||
|
|
@ -3089,6 +3321,15 @@ impl HeadlessServer {
|
|||
return false;
|
||||
}
|
||||
|
||||
let frozen_alt_screen_read = match self.alt_screen_read_conflict(&msg.request) {
|
||||
AltScreenReadConflict::None => None,
|
||||
AltScreenReadConflict::Frozen(snapshot) => Some(snapshot),
|
||||
AltScreenReadConflict::Defer => {
|
||||
self.deferred_alt_screen_reads.push(msg);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let metadata_expired = self.app.expire_due_metadata(Instant::now());
|
||||
|
||||
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
|
||||
|
|
@ -3193,6 +3434,16 @@ impl HeadlessServer {
|
|||
};
|
||||
|
||||
self.sync_foreground_client_state();
|
||||
if let Some(error) = self.agent_read_not_idle_error(&msg.request) {
|
||||
let response = serde_json::to_string(&api::schema::ErrorResponse {
|
||||
id: msg.request.id.clone(),
|
||||
error,
|
||||
})
|
||||
.unwrap_or_else(|_| "{}".to_owned());
|
||||
let _ = msg.respond_to.send(response);
|
||||
return changed;
|
||||
}
|
||||
let alt_screen_read_spec = self.alt_screen_read_spec(&msg.request);
|
||||
if matches!(
|
||||
&msg.request.method,
|
||||
api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_)
|
||||
|
|
@ -3202,7 +3453,7 @@ impl HeadlessServer {
|
|||
.handle_deferred_worktree_api_request(msg.request, msg.respond_to);
|
||||
return changed | deferred_changed;
|
||||
}
|
||||
let response = if matches!(
|
||||
let mut response = if matches!(
|
||||
&msg.request.method,
|
||||
api::schema::Method::ServerReloadConfig(_)
|
||||
) {
|
||||
|
|
@ -3228,6 +3479,37 @@ impl HeadlessServer {
|
|||
self.app
|
||||
.handle_api_request_after_internal_events_drained(msg.request)
|
||||
};
|
||||
if let Some(snapshot) = frozen_alt_screen_read {
|
||||
if let Ok(mut success) = serde_json::from_str::<api::schema::SuccessResponse>(&response)
|
||||
{
|
||||
if let api::schema::ResponseResult::PaneRead { read } = &mut success.result {
|
||||
read.text = snapshot.text;
|
||||
read.truncated = snapshot.truncated;
|
||||
if let Ok(serialized) = serde_json::to_string(&success) {
|
||||
response = serialized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(spec) = alt_screen_read_spec {
|
||||
if let Ok(success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) {
|
||||
if let api::schema::ResponseResult::PaneRead { read } = success.result {
|
||||
let pending = crate::server::alt_screen_read::PendingAltScreenRead::start(
|
||||
spec.terminal_id,
|
||||
success.id,
|
||||
msg.respond_to,
|
||||
response,
|
||||
read,
|
||||
spec.lines,
|
||||
spec.unwrap,
|
||||
spec.initial,
|
||||
Instant::now(),
|
||||
);
|
||||
self.pending_alt_screen_reads.push(pending);
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = msg.respond_to.send(response);
|
||||
|
||||
if let Some(revision_before) = pane_graphics_revision_before {
|
||||
|
|
@ -4709,6 +4991,8 @@ mod tests {
|
|||
server_config_diagnostic: None,
|
||||
server_config_diagnostic_without_keybindings: None,
|
||||
terminal_attach_owners: HashMap::new(),
|
||||
pending_alt_screen_reads: Vec::new(),
|
||||
deferred_alt_screen_reads: Vec::new(),
|
||||
next_activity_stamp: 1,
|
||||
effective_size: (MIN_COLS, MIN_ROWS),
|
||||
shutting_down: false,
|
||||
|
|
@ -5405,6 +5689,64 @@ next_tab = ""
|
|||
control_rx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_agent_history_read_requires_idle_on_alternate_screen() {
|
||||
with_terminal_session_test_server(
|
||||
|server, terminal_id, _terminal_id_string, public_pane_id| {
|
||||
let terminal = server
|
||||
.app
|
||||
.state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.expect("terminal");
|
||||
terminal.detected_agent = Some(crate::detect::Agent::Claude);
|
||||
terminal.state = crate::detect::AgentState::Working;
|
||||
server.app.terminal_runtimes.insert(
|
||||
terminal_id,
|
||||
crate::terminal::TerminalRuntime::test_with_screen_bytes(
|
||||
80,
|
||||
24,
|
||||
b"\x1b[?1049hworking",
|
||||
),
|
||||
);
|
||||
let request = api::schema::Request {
|
||||
id: "read".into(),
|
||||
method: api::schema::Method::AgentRead(api::schema::AgentReadParams {
|
||||
target: public_pane_id.clone(),
|
||||
source: api::schema::ReadSource::Recent,
|
||||
lines: Some(200),
|
||||
format: api::schema::ReadFormat::Text,
|
||||
strip_ansi: true,
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
server.agent_read_not_idle_error(&request),
|
||||
Some(api::schema::ErrorBody {
|
||||
code: "agent_not_idle".into(),
|
||||
message: format!(
|
||||
"cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible"
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
let mut default_request = request.clone();
|
||||
let api::schema::Method::AgentRead(params) = &mut default_request.method else {
|
||||
unreachable!();
|
||||
};
|
||||
params.lines = None;
|
||||
assert_eq!(server.agent_read_not_idle_error(&default_request), None);
|
||||
|
||||
let mut visible_request = request;
|
||||
let api::schema::Method::AgentRead(params) = &mut visible_request.method else {
|
||||
unreachable!();
|
||||
};
|
||||
params.source = api::schema::ReadSource::Visible;
|
||||
assert_eq!(server.agent_read_not_idle_error(&visible_request), None);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observe_allows_multiple_clients_without_attach_ownership() {
|
||||
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
|
||||
|
|
@ -5497,6 +5839,58 @@ next_tab = ""
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_rejects_attach_during_alt_screen_read() {
|
||||
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
|
||||
let (respond_to, _response_rx) = std::sync::mpsc::channel();
|
||||
server.pending_alt_screen_reads.push(
|
||||
crate::server::alt_screen_read::PendingAltScreenRead::start(
|
||||
terminal_id,
|
||||
"read".into(),
|
||||
respond_to,
|
||||
"fallback".into(),
|
||||
api::schema::PaneReadResult {
|
||||
pane_id: "w1:p1".into(),
|
||||
workspace_id: "w1".into(),
|
||||
tab_id: "w1:t1".into(),
|
||||
source: api::schema::ReadSource::Recent,
|
||||
format: api::schema::ReadFormat::Text,
|
||||
text: String::new(),
|
||||
revision: 0,
|
||||
truncated: false,
|
||||
},
|
||||
120,
|
||||
false,
|
||||
crate::terminal::ScreenSnapshot {
|
||||
cols: 80,
|
||||
rows: Vec::new(),
|
||||
},
|
||||
Instant::now(),
|
||||
),
|
||||
);
|
||||
let control_rx = connect_pending_terminal_client_with_control_rx(server, 7);
|
||||
|
||||
assert!(
|
||||
!server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||||
client_id: 7,
|
||||
target: terminal_id_string.clone(),
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
assert!(!server.clients.contains_key(&7));
|
||||
assert!(!server
|
||||
.terminal_attach_owners
|
||||
.contains_key(&terminal_id_string));
|
||||
let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
|
||||
assert_eq!(
|
||||
reason,
|
||||
Some(format!(
|
||||
"terminal attach failed: terminal {terminal_id_string} has a read in progress; retry"
|
||||
))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_rejects_second_controller_without_takeover() {
|
||||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
mod alt_screen_read;
|
||||
pub mod autodetect;
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod client_accept;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
use crate::ghostty::{CellWide, ScreenTextRow};
|
||||
use crate::pane::TerminalReadSnapshot;
|
||||
|
||||
const MIN_ALIGNMENT_RATIO_PERCENT: usize = 30;
|
||||
const SIMILAR_VIEWPORT_RATIO_PERCENT: usize = 70;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ScreenSnapshot {
|
||||
pub(crate) cols: u16,
|
||||
pub(crate) rows: Vec<ScreenTextRow>,
|
||||
}
|
||||
|
||||
impl ScreenSnapshot {
|
||||
pub(crate) fn similar_text(&self, other: &Self) -> bool {
|
||||
if self.cols != other.cols || self.rows.len() != other.rows.len() {
|
||||
return false;
|
||||
}
|
||||
let left = row_identities(&self.rows);
|
||||
let right = row_identities(&other.rows);
|
||||
let comparable = left
|
||||
.iter()
|
||||
.zip(&right)
|
||||
.filter(|(left, right)| !left.is_empty() || !right.is_empty())
|
||||
.count();
|
||||
if comparable == 0 {
|
||||
return true;
|
||||
}
|
||||
let matches = left
|
||||
.iter()
|
||||
.zip(&right)
|
||||
.filter(|(left, right)| left == right && (!left.is_empty() || !right.is_empty()))
|
||||
.count();
|
||||
matches.saturating_mul(100) >= comparable.saturating_mul(SIMILAR_VIEWPORT_RATIO_PERCENT)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UpwardMerge {
|
||||
Advanced { rows: usize },
|
||||
Unchanged,
|
||||
Unaligned,
|
||||
}
|
||||
|
||||
pub(crate) fn merge_scrolled_up(
|
||||
history: &mut Vec<ScreenTextRow>,
|
||||
previous: &ScreenSnapshot,
|
||||
next: &ScreenSnapshot,
|
||||
) -> UpwardMerge {
|
||||
if previous.cols != next.cols || previous.rows.len() != next.rows.len() {
|
||||
return UpwardMerge::Unaligned;
|
||||
}
|
||||
let previous_text = row_identities(&previous.rows);
|
||||
let next_text = row_identities(&next.rows);
|
||||
if previous_text == next_text {
|
||||
return UpwardMerge::Unchanged;
|
||||
}
|
||||
let Some(shift) = best_upward_shift(&previous_text, &next_text) else {
|
||||
return UpwardMerge::Unaligned;
|
||||
};
|
||||
let Some(boundary) = (0..previous_text.len().saturating_sub(shift)).find_map(|index| {
|
||||
let next_index = index + shift;
|
||||
(!previous_text[index].is_empty() && previous_text[index] == next_text[next_index])
|
||||
.then_some(next_index)
|
||||
}) else {
|
||||
return UpwardMerge::Unaligned;
|
||||
};
|
||||
let added: Vec<_> = next.rows[..boundary]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| {
|
||||
next_text[*index].is_empty() || previous_text.get(*index) != Some(&next_text[*index])
|
||||
})
|
||||
.map(|(_, row)| row.clone())
|
||||
.collect();
|
||||
if added.is_empty() {
|
||||
return UpwardMerge::Unaligned;
|
||||
}
|
||||
let rows = added.len();
|
||||
history.splice(0..0, added);
|
||||
UpwardMerge::Advanced { rows }
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot_text(
|
||||
rows: &[ScreenTextRow],
|
||||
lines: usize,
|
||||
unwrap: bool,
|
||||
truncated: bool,
|
||||
) -> TerminalReadSnapshot {
|
||||
let start = rows.len().saturating_sub(lines);
|
||||
let rows = &rows[start..];
|
||||
let text = if unwrap {
|
||||
unwrapped_text(rows)
|
||||
} else {
|
||||
wrapped_text(rows)
|
||||
};
|
||||
TerminalReadSnapshot { text, truncated }
|
||||
}
|
||||
|
||||
fn best_upward_shift(previous: &[String], next: &[String]) -> Option<usize> {
|
||||
let mut best = None;
|
||||
for shift in 1..previous.len() {
|
||||
let overlap = previous.len() - shift;
|
||||
let mut comparable = 0usize;
|
||||
let mut matches = 0usize;
|
||||
for index in 0..overlap {
|
||||
let before = &previous[index];
|
||||
let after = &next[index + shift];
|
||||
if before.is_empty() || after.is_empty() {
|
||||
continue;
|
||||
}
|
||||
comparable += 1;
|
||||
if before == after {
|
||||
matches += 1;
|
||||
}
|
||||
}
|
||||
if comparable == 0
|
||||
|| matches.saturating_mul(100) < comparable.saturating_mul(MIN_ALIGNMENT_RATIO_PERCENT)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(_, best_matches, best_comparable)| {
|
||||
matches > best_matches || (matches == best_matches && comparable > best_comparable)
|
||||
}) {
|
||||
best = Some((shift, matches, comparable));
|
||||
}
|
||||
}
|
||||
best.map(|(shift, _, _)| shift)
|
||||
}
|
||||
|
||||
fn row_identities(rows: &[ScreenTextRow]) -> Vec<String> {
|
||||
rows.iter()
|
||||
.map(|row| row_text(row).trim_end().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wrapped_text(rows: &[ScreenTextRow]) -> String {
|
||||
let mut lines: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|row| row_text(row).trim_end().to_string())
|
||||
.collect();
|
||||
while lines.last().is_some_and(|line| line.trim().is_empty()) {
|
||||
lines.pop();
|
||||
}
|
||||
lines_to_text(lines)
|
||||
}
|
||||
|
||||
fn unwrapped_text(rows: &[ScreenTextRow]) -> String {
|
||||
let mut lines = Vec::new();
|
||||
let mut current = String::new();
|
||||
for row in rows {
|
||||
let text = row_text(row);
|
||||
if row.soft_wrapped {
|
||||
current.push_str(text.trim_end());
|
||||
} else {
|
||||
current.push_str(text.trim_end());
|
||||
lines.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
lines.push(current);
|
||||
}
|
||||
while lines.last().is_some_and(|line| line.trim().is_empty()) {
|
||||
lines.pop();
|
||||
}
|
||||
lines_to_text(lines)
|
||||
}
|
||||
|
||||
fn lines_to_text(lines: Vec<String>) -> String {
|
||||
let text = lines.join("\n");
|
||||
if text.is_empty() {
|
||||
text
|
||||
} else {
|
||||
format!("{text}\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn row_text(row: &ScreenTextRow) -> String {
|
||||
let mut text = String::new();
|
||||
for cell in &row.cells {
|
||||
if cell.wide == CellWide::SpacerTail {
|
||||
continue;
|
||||
}
|
||||
if cell.graphemes.is_empty()
|
||||
|| cell.graphemes.first().copied() == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER)
|
||||
{
|
||||
text.push(' ');
|
||||
} else {
|
||||
text.extend(cell.graphemes.iter().map(|codepoint| {
|
||||
char::from_u32(*codepoint).unwrap_or(char::REPLACEMENT_CHARACTER)
|
||||
}));
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ghostty::{ScreenTextCell, ScreenTextRow};
|
||||
|
||||
fn row(text: &str) -> ScreenTextRow {
|
||||
ScreenTextRow {
|
||||
cells: text
|
||||
.chars()
|
||||
.map(|ch| ScreenTextCell {
|
||||
wide: CellWide::Narrow,
|
||||
graphemes: vec![ch as u32],
|
||||
})
|
||||
.collect(),
|
||||
soft_wrapped: false,
|
||||
wrap_continuation: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(lines: &[&str]) -> ScreenSnapshot {
|
||||
ScreenSnapshot {
|
||||
cols: 20,
|
||||
rows: lines.iter().map(|line| row(line)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_similarity_tolerates_small_dynamic_regions() {
|
||||
let initial = snapshot(&["line 1", "line 2", "worked for 2s", "prompt"]);
|
||||
let status_changed = snapshot(&["line 1", "line 2", "worked for 3s", "prompt"]);
|
||||
let scrolled = snapshot(&["older", "line 1", "line 2", "prompt"]);
|
||||
|
||||
assert!(initial.similar_text(&status_changed));
|
||||
assert!(!initial.similar_text(&scrolled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controlled_upward_scroll_prepends_only_new_rows() {
|
||||
let previous = snapshot(&["line 3", "line 4", "line 5", "status"]);
|
||||
let next = snapshot(&["line 1", "line 2", "line 3", "line 4"]);
|
||||
let mut history = previous.rows.clone();
|
||||
|
||||
assert_eq!(
|
||||
merge_scrolled_up(&mut history, &previous, &next),
|
||||
UpwardMerge::Advanced { rows: 2 }
|
||||
);
|
||||
assert_eq!(
|
||||
row_identities(&history),
|
||||
["line 1", "line 2", "line 3", "line 4", "line 5", "status"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_header_is_not_repeated_or_counted_as_scrolled_history() {
|
||||
let previous = snapshot(&["sticky", "line 4", "line 5", "line 6", "line 7"]);
|
||||
let next = snapshot(&["sticky", "line 2", "line 3", "line 4", "line 5"]);
|
||||
let mut history = previous.rows.clone();
|
||||
|
||||
assert_eq!(
|
||||
merge_scrolled_up(&mut history, &previous, &next),
|
||||
UpwardMerge::Advanced { rows: 2 }
|
||||
);
|
||||
assert_eq!(
|
||||
row_identities(&history),
|
||||
["line 2", "line 3", "sticky", "line 4", "line 5", "line 6", "line 7"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_and_unaligned_frames_do_not_change_history() {
|
||||
let previous = snapshot(&["line 1", "line 2", "line 3"]);
|
||||
let mut history = previous.rows.clone();
|
||||
|
||||
assert_eq!(
|
||||
merge_scrolled_up(&mut history, &previous, &previous),
|
||||
UpwardMerge::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
merge_scrolled_up(
|
||||
&mut history,
|
||||
&previous,
|
||||
&snapshot(&["other a", "other b", "other c"]),
|
||||
),
|
||||
UpwardMerge::Unaligned
|
||||
);
|
||||
assert_eq!(history, previous.rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_text_limits_rendered_rows_before_unwrapping() {
|
||||
let mut first = row("hello ");
|
||||
first.soft_wrapped = true;
|
||||
let mut second = row("world");
|
||||
second.wrap_continuation = true;
|
||||
let rows = vec![row("older"), first, second];
|
||||
|
||||
assert_eq!(
|
||||
snapshot_text(&rows, 2, false, true),
|
||||
TerminalReadSnapshot {
|
||||
text: "hello\nworld\n".into(),
|
||||
truncated: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot_text(&rows, 2, true, true),
|
||||
TerminalReadSnapshot {
|
||||
text: "helloworld\n".into(),
|
||||
truncated: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
mod history_read;
|
||||
mod id;
|
||||
mod runtime;
|
||||
mod runtime_registry;
|
||||
pub mod state;
|
||||
mod title;
|
||||
|
||||
pub(crate) use history_read::{merge_scrolled_up, snapshot_text, ScreenSnapshot, UpwardMerge};
|
||||
pub use id::TerminalId;
|
||||
pub use runtime::TerminalRuntime;
|
||||
pub(crate) use runtime_registry::TerminalRuntimeRegistry;
|
||||
|
|
|
|||
|
|
@ -433,6 +433,16 @@ impl TerminalRuntime {
|
|||
self.0.wheel_routing()
|
||||
}
|
||||
|
||||
pub(crate) fn screen_text_snapshot(
|
||||
&self,
|
||||
) -> Option<(
|
||||
crate::ghostty::ActiveScreen,
|
||||
crate::terminal::ScreenSnapshot,
|
||||
)> {
|
||||
let (screen, cols, rows) = self.0.screen_text_snapshot()?;
|
||||
Some((screen, crate::terminal::ScreenSnapshot { cols, rows }))
|
||||
}
|
||||
|
||||
pub fn encode_mouse_button(
|
||||
&self,
|
||||
kind: crossterm::event::MouseEventKind,
|
||||
|
|
|
|||
Loading…
Reference in New Issue