Merge branch 'master' into issue/4320-reboot-session-loss

This commit is contained in:
JJ Liebig
2026-09-20 20:01:59 +02:00
committed by GitHub
44 changed files with 2621 additions and 270 deletions
+9 -7
View File
@@ -1,7 +1,7 @@
id = "codex"
version = "2026.09.14.1"
version = "2026.09.15.1"
min_engine_version = 3
updated_at = "2026-09-14T00:00:00Z"
updated_at = "2026-09-15T00:00:00Z"
[[rules]]
id = "osc_title_blocked"
@@ -84,11 +84,13 @@ state = "working"
priority = 500
region = "before_current_prompt_marker"
visible_working = true
# Cheap negative filter before the multiline search.
contains = ["esc to interrupt"]
# Animations can hide the bullet, and queued inputs can sit below a dynamic activity label.
# Require the live timer/control suffix with no later response or interruption marker.
regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s • esc to interrupt\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+esc\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
# Support animated and reduced-motion status, including dynamic activity labels.
# The interrupt hint can be remapped, unbound, or hidden, and queued inputs can
# sit below the status. Require the live timer suffix with no later response.
any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }]
regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
# A failed reconnect keeps its final elapsed timer but is no longer working.
not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }]
[[rules]]
id = "osc_title_idle"
+72 -38
View File
@@ -1,63 +1,97 @@
id = "kiro"
version = "2026.08.01.1"
min_engine_version = 1
updated_at = "2026-08-01T00:00:00Z"
version = "2026.09.19.1"
min_engine_version = 2
updated_at = "2026-09-19T00:00:00Z"
aliases = ["kiro-cli"]
[[rules]]
id = "live_prompt_idle"
state = "idle"
priority = 1100
region = "bottom_non_empty_lines(4)"
visible_idle = true
line_regex = ['(?i)^\s*[>]\s*ask a question or describe a task(?:\s+(?:enter|↵))?\s*$']
[[rules]]
id = "tool_approval"
state = "blocked"
priority = 300
region = "whole_recent"
priority = 1050
region = "bottom_non_empty_lines(8)"
visible_blocker = true
contains = ["requires approval"]
any = [
{ contains = ["yes, single permission"] },
{ contains = ["trust, always allow"] },
{ contains = ["no (tab to edit)"] },
{ contains = ["esc to close"] },
{ all = [
{ regex = ['(?is)(?:^|\n)\s*esc\s+to\s+close\b(?:[^\n]*\bto\s+navigate\b[^\n]*|.*\bto\s+navigate\b.*\bto\s+select\b.*\btab\s+to\s+edit|[^\n]*\benter\s+to\s+see\s+more\s+options)\s*\z'] },
{ any = [
{ line_regex = [
'(?i)^\s*[>]\s*(?:allow|always allow|deny|always deny)\s*$',
'(?i)^\s*(?:[>]\s*)?allow\s*$',
'(?i)^\s*(?:[>]\s*)?always allow\s*$',
'(?i)^\s*(?:[>]\s*)?deny\s*$',
'(?i)^\s*(?:[>]\s*)?always deny\s*$',
] },
{ line_regex = [
'(?i)^\s*[>]\s*(?:yes, single permission|trust, always allow in this session|no \(tab to edit\))\s*$',
'(?i)^\s*(?:[>]\s*)?yes, single permission\s*$',
'(?i)^\s*(?:[>]\s*)?trust, always allow in this session\s*$',
'(?i)^\s*(?:[>]\s*)?no \(tab to edit\)\s*$',
] },
{ line_regex = [
'(?i)^\s*[>]\s*(?:trust\b.*|entire tool)\s*$',
'(?i)^\s*(?:[>]\s*)?(?:trust )?entire tool(?:\s+\([^\n]*\))?(?:\s+(?:session|workspace|always))?\s*$',
] },
] },
] },
]
[[rules]]
id = "subagent_approval"
id = "tool_approval_edit"
state = "blocked"
priority = 290
priority = 1045
region = "whole_recent"
visible_blocker = true
contains = ["pending from subagents"]
any = [
{ contains = ["tool approval"] },
{ contains = ["tool approvals"] },
]
all = [
{ any = [{ contains = ["approve all pending"] }, { contains = ["configure individually"] }, { contains = ["exit (cancel subagents)"] }] },
regex = ['(?im)(?:^|\n)[ \t]*[-─]+[ \t]*\n[^\n]*requires\s+approval\s*[·.]\s*modify\s+request[ \t]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*?[ \t]*[>][ \t]*[^\n]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*[ \t]*[-─]+[ \t]*\n[ \t]*esc[ \t]+to[ \t]+close[ \t]*\n?\z']
[[rules]]
id = "crew_approval"
state = "blocked"
priority = 1040
region = "bottom_non_empty_lines(8)"
visible_blocker = true
contains = [
"tool approval",
"approve all pending",
"configure individually (agent monitor)",
"exit (cancel subagents)",
]
[[rules]]
id = "prompt_idle"
state = "idle"
priority = 200
region = "bottom_non_empty_lines(5)"
visible_idle = true
contains = ["ask a question or describe a task", "/copy to clipboard"]
not = [
{ contains = ["kiro is working"] },
{ contains = ["esc to cancel"] },
]
id = "question_panel"
state = "blocked"
priority = 1030
region = "bottom_non_empty_lines(8)"
visible_blocker = true
contains = ["to navigate", "to submit", "esc to cancel"]
[[rules]]
id = "kiro_working_marker"
id = "live_working_footer"
state = "working"
priority = 100
region = "whole_recent"
priority = 950
region = "bottom_non_empty_lines(4)"
visible_working = true
contains = ["kiro is working"]
contains = ["kiro is working", "type to steer", "ctrl+s to queue"]
[[rules]]
id = "tool_spinner_working"
id = "osc_title_working"
state = "working"
priority = 90
region = "whole_recent"
priority = 900
region = "osc_title"
visible_working = true
contains = ["esc to cancel"]
line_regex = ['^\s*(◔|◑|◕|●)\s+\p{Alphabetic}']
regex = ['(?i)^[◐◓◑◒/|\\-]\s+kiro:']
[[rules]]
id = "osc_progress_working"
state = "working"
priority = 890
region = "osc_progress"
visible_working = true
regex = ['^4;3;?$']
@@ -197,6 +197,8 @@ herdr tab close <tab_id>
A tab is another terminal layout inside a workspace. Without `--workspace`, `tab create` uses the active workspace and fails if none exists. Its JSON response exposes `.result.tab.tab_id` and `.result.root_pane.pane_id`. Closing a workspace's last tab also closes the workspace, matching the TUI close-tab action. If `confirm_close` is enabled and closing the tab would also close a whole worktree group, `tab close` returns a `confirmation_required` error instead.
With `ui.confirm_close` enabled, the TUI asks for confirmation before closing a workspace's last tab from a keybinding or the tab menu. Ordinary CLI/API tab closes remain immediate.
Workspace and tab creation, and pane splitting, leave focus unchanged by default. `--focus` selects the new layout; `--no-focus` states the default explicitly. Without `--cwd`, new terminals follow the configured `terminal.new_cwd` policy, which follows the source pane or workspace by default. Each `--env KEY=VALUE` adds or replaces that variable in the new root shell.
## Panes
@@ -191,7 +191,9 @@ herdr tab rename <tab_id> <label>
herdr tab close <tab_id>
```
タブはワークスペース内の別のターミナルレイアウトです。`--workspace` を省略すると、`tab create` はアクティブなワークスペースを使い、存在しなければ失敗します。JSON レスポンスの `.result.tab.tab_id` と `.result.root_pane.pane_id` から ID を取得できます。
タブはワークスペース内の別のターミナルレイアウトです。`--workspace` を省略すると、`tab create` はアクティブなワークスペースを使い、存在しなければ失敗します。JSON レスポンスの `.result.tab.tab_id` と `.result.root_pane.pane_id` から ID を取得できます。ワークスペースの最後のタブを閉じると、そのワークスペースも閉じられます。`ui.confirm_close` が有効で、親ワークツリーの最後のタブを閉じるとワークツリーグループ全体が閉じられる場合、`tab close` は `confirmation_required` エラーを返します。
`ui.confirm_close` が有効な場合、キーバインドやタブメニューからワークスペースの最後のタブを閉じる前に、TUI が確認を求めます。通常の CLI/API によるタブの終了は引き続き即座に実行されます。
ワークスペースとタブの作成、およびペインの分割は、デフォルトではフォーカスを変えません。`--focus` は新しいレイアウトを選択し、`--no-focus` はデフォルトを明示します。`--cwd` がない場合、新しいターミナルは `terminal.new_cwd` の設定に従い、デフォルトでは元のペインまたはワークスペースを引き継ぎます。各 `--env KEY=VALUE` は新しいルートシェルの環境変数を追加または置換します。
@@ -111,7 +111,7 @@ herdr pane read w1:p2 --source recent --lines 50
`workspace.move_block` は、順序付きの `workspace_ids` を `before_workspace_id` の前へアトミックに移動します。アンカーを省略するとブロックを末尾へ移動します。id は一意である必要があり、アンカーをブロックに含めることはできません。レスポンスにはサーバーが確定した順序付きワークスペース一覧が含まれます。
`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。ブートストラップ中のイベントを取りこぼさないよう、まず別の接続で `events.subscribe` を開いて確認応答を待ち、そのストリームをバッファしながら `session.snapshot` を呼び出します。スナップショットを適用してから、バッファしたイベントを順番に適用し、そのままストリーミングを続けます。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。
`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。キャッシュの初期化と復旧では、先に購読を開始し、後述のイベント購読セクションの手順でサーバーから現在の状態を読み取り、キャッシュを照合・更新します。イベントはキャッシュの無効化を知らせるものであり、スナップショットに無条件で再適用できるものではありません。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。
CLI の `herdr api snapshot` は、クライアントやエージェントが簡単にブートストラップできるよう、現在の `session.snapshot` レスポンスを JSON で出力します。
@@ -639,10 +639,16 @@ workspace の get/list 応答は結果の `tokens` マップを公開し、ス
最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。ライフサイクルイベントの購読はリクエストが受理された時点から始まり、それ以前に保持されていたイベントは再生しません。
ライフサイクルとエージェント状態のイベントは、各購読内の順序を保った上限付きのバッチで送信されます。イベント履歴は永続ログではありません。購読の初期化中も含め、購読者が保持範囲より遅れた場合、サーバーは元のリクエスト `id` と `error.code: "events_lost"` を含むエラーレスポンスを送り、その購読接続を閉じます。欠落したイベントを通知せずに配信を続けることはありません。他のクライアント接続には影響しません。履歴はイベント種別間で共有されるため、失われたイベントが購読条件に一致しなかった可能性があっても、保持範囲を超えたことを通知します。
`events_lost` を受け取ったら、キャッシュを古い状態として扱います。新しい購読を開始して `subscription_started` を待ち、読み取りを続けながら別の接続で `session.snapshot` を取得してください。サーバーの現在の状態でキャッシュを照合し、スナップショットでキャッシュ全体を置き換えます。イベントはキャッシュの無効化通知として扱い、再取得の契機にします。取得は直列に行い、取得中にイベントが届いた場合は完了後に再取得してください。
スナップショットとイベントに共通のシーケンス境界はありません。順序が保証されるのは各購読項目内のみで、項目間やスナップショットとの前後関係は保証されません。バッファしたイベントの内容をスナップショットへ無条件に再適用しないでください。復旧できるのは現在の状態であり、失われたイベント履歴ではありません。再び `events_lost` を受け取った場合は、再購読して状態を照合し直します。読み取りが遅いクライアントでは、読み取り速度を上げるかイベントの負荷を減らす必要があります。
ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。`workspace.moved` は、移動した `workspace_id`、要求された `insert_index`、更新済みの順序付き `workspaces` リストを含みます。`workspace.reordered` は、アトミックに移動した `workspace_ids`、省略可能な `before_workspace_id`、サーバーが確定した順序付き `workspaces` リストを含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。
タブのイベント購読には `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed`、`tab.moved` があります。`tab.moved` は、移動した `tab_id`、`workspace_id`、要求された `insert_index`、そのワークスペースの更新済みの順序付き `tabs` リストを含みます。
ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed`、`pane.scroll_changed` があります。`pane.focused` は、このサーバーに接続した任意のクライアントでの手動ペイン選択も報告します。他のクライアントの表示は移動させず、ペイロードにクライアントの識別情報は含みません。選択済みのペインを再度選んでも発行しません。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。`pane.scroll_changed` は 1 つの `pane_id` を対象とし、Herdr がスクロールスナップショットの変化を検出するたびに `pane_id`、`workspace_id`、現在の `scroll` 情報を発行します。
レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でブートストラップするクライアントは、同じ `workspace_id` と `tab_id` のキャッシュ済みレイアウトを置き換えてください。
レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でキャッシュを管理するクライアントは、このイベントを契機にサーバーから対象のレイアウトを再取得してください。遅れて届いたペイロードは、最新のスナップショットより古い可能性があります。
worktree のイベント購読には `worktree.created`、`worktree.opened`、`worktree.removed` があります。worktree イベントは Git チェックアウトのライフサイクルを記述します。`worktree.created` は開かれた `workspace` と作成された `worktree` を含みます。`worktree.opened` は対象の `workspace`、開かれた `worktree`、`already_open` を含みます。`worktree.removed` は `workspace_id`、削除された `worktree`、`forced` を含みます。
@@ -118,12 +118,11 @@ Raw socket method names use dot notation:
`session.snapshot` returns a one-time bootstrap snapshot for clients that keep
their own local runtime cache. The response includes version/protocol metadata,
focused workspace/tab/pane ids, workspace records, tab records, pane records,
tab layout snapshots, and agent records. It is not a subscription. To avoid a
bootstrap gap, first open `events.subscribe` on another connection and wait for
its acknowledgement. Buffer that stream while calling `session.snapshot`,
install the snapshot, then apply the buffered events in order and continue
streaming. Call `session.snapshot` again after reconnecting or when the local
cache may be stale. Attached worktree provenance is included on workspace
tab layout snapshots, and agent records. It is not a subscription. For cache
bootstrap and recovery, subscribe first, then reconcile through authoritative
reads as described under Event subscriptions below. Events invalidate cached state; they
cannot be unconditionally replayed over a snapshot. Call `session.snapshot`
again after reconnecting or when the local cache may be stale. Attached worktree provenance is included on workspace
records. Full repo worktree discovery remains `worktree.list`.
From the CLI, `herdr api snapshot` prints the live `session.snapshot` response
@@ -825,6 +824,29 @@ and retry rather than assuming the other entries were subscribed.
Lifecycle subscriptions start when the request is accepted and do not replay
events retained before that point.
Lifecycle and agent-status events are drained in bounded batches, in order within
each subscription. Event history is not durable. If a subscriber falls behind
the retained history, including while its subscriptions are being initialized,
the server sends an error response with the original request `id` and
`error.code: "events_lost"`, then closes that subscription connection instead of
silently continuing with missing events. Other clients remain connected.
The history is shared across event types, so an overrun is reported even when
the evicted events might not have matched this subscription's filters.
On `events_lost`, treat the cached state as stale. Open a new subscription,
wait for `subscription_started`, and keep reading while requesting
`session.snapshot` on another connection. Use authoritative reads to reconcile
current state: replace the cache with the snapshot, and treat incoming events
as invalidation signals that trigger another read. Serialize refreshes and
refresh again if events arrive while a read is in flight.
Snapshots and events have no shared sequence boundary. Ordering is guaranteed
within each subscription entry, not across entries or relative to a snapshot,
so do not unconditionally replay buffered event payloads onto the snapshot.
Recovery restores current state, not the missing event history. Another
`events_lost` requires resubscribing and reconciling again. Slow readers may
need to increase read throughput or reduce event load.
Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.metadata_updated`, `workspace.renamed`, `workspace.moved`, `workspace.reordered`, `workspace.closed`, and `workspace.focused`. `workspace.metadata_updated` reports token changes and TTL expiry without invoking plugin event hooks. Other workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.reordered` includes the atomically moved `workspace_ids`, optional `before_workspace_id`, and authoritative ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal.
Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`,
`tab.renamed`, and `tab.moved`. `tab.moved` includes the moved `tab_id`,
@@ -841,9 +863,9 @@ Terminal-title changes can emit `pane.updated`, but spinner-only raw-title chang
`workspace_id`, and the current `scroll` metrics whenever Herdr observes a
changed scroll snapshot.
Layout event subscriptions include `layout.updated`. The event carries the
updated `PaneLayoutSnapshot` for one tab. Clients that bootstrap with
`session.snapshot` should replace the cached layout with the same
`workspace_id` and `tab_id`.
updated `PaneLayoutSnapshot` for one tab. Clients maintaining a cache with
`session.snapshot` should use this event to refresh the affected layout through
an authoritative read; a delayed payload may predate their latest snapshot.
Worktree event subscriptions include `worktree.created`, `worktree.opened`, and `worktree.removed`. Worktree events describe Git checkout lifecycle. `worktree.created` includes the opened `workspace` and created `worktree`. `worktree.opened` includes the target `workspace`, opened `worktree`, and `already_open`. `worktree.removed` includes the `workspace_id`, removed `worktree`, and `forced`.
@@ -191,7 +191,9 @@ herdr tab rename <tab_id> <label>
herdr tab close <tab_id>
```
标签页是工作区内的另一个终端布局。不指定 `--workspace` 时,`tab create` 使用活动工作区;如果没有活动工作区则失败。JSON 响应中的 `.result.tab.tab_id` 和 `.result.root_pane.pane_id` 是对应 ID。
标签页是工作区内的另一个终端布局。不指定 `--workspace` 时,`tab create` 使用活动工作区;如果没有活动工作区则失败。JSON 响应中的 `.result.tab.tab_id` 和 `.result.root_pane.pane_id` 是对应 ID。关闭工作区的最后一个标签页也会关闭该工作区。启用 `ui.confirm_close` 时,如果关闭父工作树的最后一个标签页会连带关闭整个工作树组,`tab close` 会返回 `confirmation_required` 错误。
启用 `ui.confirm_close` 时,通过快捷键或标签页菜单关闭工作区的最后一个标签页前,TUI 会要求确认。普通的 CLI/API 标签页关闭操作仍立即执行。
创建工作区、标签页和拆分窗格默认不改变焦点。`--focus` 选择新布局;`--no-focus` 显式表达默认行为。不指定 `--cwd` 时,新终端遵循 `terminal.new_cwd` 设置,默认跟随来源窗格或工作区。每个 `--env KEY=VALUE` 在新的根 shell 中添加或替换一个环境变量。
@@ -111,7 +111,7 @@ herdr pane read w1:p2 --source recent --lines 50
`workspace.move_block` 会将有序的 `workspace_ids` 原子地移动到 `before_workspace_id` 之前;省略锚点则将该块移动到末尾。id 必须唯一,且锚点不能属于被移动的块。响应包含服务器确认的有序工作区列表。
`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅。为避免引导期间漏掉事件,应先在另一个连接上打开 `events.subscribe` 并等待确认,在调用 `session.snapshot` 时缓冲该事件流,然后安装快照、按顺序应用缓冲的事件并继续接收事件。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。
`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅。初始化或恢复缓存时,应先建立订阅,再按下文事件订阅部分的说明读取服务器当前状态并核对、更新缓存。事件表示缓存可能过期,不能无条件重放到快照上。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。
CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSON,让客户端和智能体可以简单地完成引导。
@@ -635,10 +635,16 @@ workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏
第一个响应确认订阅。之后的行是推送的事件。生命周期事件订阅从请求被接受时开始,不会重放在此之前保留的事件。
生命周期和智能体状态事件按有界批次发送,每个订阅内保持事件顺序。事件历史不是持久日志。如果订阅者落后到保留范围之外,包括订阅初始化期间,服务器会发送包含原请求 `id` 和 `error.code: "events_lost"` 的错误响应,然后关闭该订阅连接,而不是静默跳过缺失事件后继续发送。其他客户端的连接不受影响。各类事件共享历史缓冲,因此即使被淘汰的事件可能不匹配此订阅的过滤条件,仍会报告溢出。
收到 `events_lost` 后,将缓存状态视为过期。建立新订阅并等待 `subscription_started`,然后持续读取,同时在另一条连接上请求 `session.snapshot`。通过服务器返回的当前状态重新核对缓存:用快照整体替换缓存,将收到的事件作为缓存失效通知并触发再次读取。串行执行刷新;如果读取期间又收到事件,完成后再次刷新。
快照和事件没有共同的序列边界。顺序仅在每个订阅条目内保证,不保证跨条目或相对于快照的顺序,因此不要把缓冲的事件载荷无条件重放到快照上。恢复得到的是当前状态,无法补回丢失的事件历史。再次收到 `events_lost` 时,需要重新订阅并核对状态。读取过慢的客户端可能需要提高读取速度或减少事件负载。
工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。`workspace.moved` 包含被移动的 `workspace_id`、请求的 `insert_index` 和更新后的有序 `workspaces` 列表。`workspace.reordered` 包含原子移动的 `workspace_ids`、可选的 `before_workspace_id` 和服务器确认的有序 `workspaces` 列表。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。
标签页事件订阅包括 `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed` 和 `tab.moved`。`tab.moved` 包含被移动的 `tab_id`、`workspace_id`、请求的 `insert_index` 和该工作区更新后的有序 `tabs` 列表。
窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` 和 `pane.scroll_changed`。`pane.focused` 也会上报任意连接到此服务器的客户端手动选择窗格的变化,不会移动其他客户端的视图,且载荷不包含客户端身份。重复选择已选中的窗格不会再次发出事件。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。`pane.scroll_changed` 只针对一个 `pane_id`,每当 Herdr 观察到滚动快照变化时,都会发出 `pane_id`、`workspace_id` 和当前 `scroll` 指标。
布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 引导的客户端应替换具有相同 `workspace_id` 和 `tab_id` 的缓存布局
布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 维护缓存的客户端应将该事件作为重新读取受影响布局的通知;延迟到达的载荷可能早于最新快照
Worktree 事件订阅包括 `worktree.created`、`worktree.opened` 和 `worktree.removed`。Worktree 事件描述 Git 检出的生命周期。`worktree.created` 包含打开的 `workspace` 和创建的 `worktree`。`worktree.opened` 包含目标 `workspace`、打开的 `worktree` 和 `already_open`。`worktree.removed` 包含 `workspace_id`、被移除的 `worktree` 和 `forced`。
+81
View File
@@ -9,6 +9,12 @@ struct EventHubState {
events: Vec<(u64, crate::api::schema::EventEnvelope)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum EventHistoryError {
Lost,
Unavailable,
}
impl EventHub {
const MAX_EVENTS: usize = 512;
@@ -37,6 +43,29 @@ impl EventHub {
.collect()
}
pub(super) fn events_after_checked(
&self,
sequence: u64,
) -> Result<Vec<(u64, crate::api::schema::EventEnvelope)>, EventHistoryError> {
let state = self
.inner
.lock()
.map_err(|_| EventHistoryError::Unavailable)?;
if state
.events
.first()
.is_some_and(|(first, _)| sequence < first.saturating_sub(1))
{
return Err(EventHistoryError::Lost);
}
Ok(state
.events
.iter()
.filter(|(event_sequence, _)| *event_sequence > sequence)
.cloned()
.collect())
}
pub fn current_sequence(&self) -> u64 {
let Ok(state) = self.inner.lock() else {
return 0;
@@ -44,3 +73,55 @@ impl EventHub {
state.next_sequence
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::schema::{EventData, EventEnvelope, EventKind};
fn event() -> EventEnvelope {
EventEnvelope {
event: EventKind::WorkspaceFocused,
data: EventData::WorkspaceFocused {
workspace_id: "workspace_1".into(),
},
}
}
#[test]
fn checked_history_distinguishes_retained_boundary_from_lost_events() {
let hub = EventHub::default();
assert!(hub.events_after_checked(0).unwrap().is_empty());
for _ in 0..EventHub::MAX_EVENTS {
hub.push(event());
}
assert_eq!(
hub.events_after_checked(0).unwrap().len(),
EventHub::MAX_EVENTS
);
hub.push(event());
assert_eq!(hub.events_after_checked(0), Err(EventHistoryError::Lost));
let retained = hub.events_after_checked(1).unwrap();
assert_eq!(retained.len(), EventHub::MAX_EVENTS);
assert_eq!(retained.first().unwrap().0, 2);
assert_eq!(retained.last().unwrap().0, hub.current_sequence());
assert!(hub
.events_after_checked(hub.current_sequence())
.unwrap()
.is_empty());
}
#[test]
fn checked_history_reports_unavailable_instead_of_empty_after_poison() {
let hub = EventHub::default();
assert!(std::panic::catch_unwind(|| {
let _guard = hub.inner.lock().unwrap();
panic!("poison the test event history");
})
.is_err());
assert_eq!(
hub.events_after_checked(0),
Err(EventHistoryError::Unavailable)
);
}
}
+21 -2
View File
@@ -24,6 +24,9 @@ use crate::ipc::{
mod pane_graphics_stream;
#[cfg(test)]
mod subscription_socket_tests;
const SOCKET_PERMISSION_MODE: u32 = 0o600;
pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100);
pub(super) const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
@@ -749,7 +752,7 @@ fn stream_subscriptions(
if let Err(err) = write_json_line(
&mut stream,
&SuccessResponse {
id: request_id,
id: request_id.clone(),
result: ResponseResult::SubscriptionStarted {},
},
) {
@@ -765,7 +768,23 @@ fn stream_subscriptions(
}
for subscription in &mut subscriptions {
if let Some(event) = subscription.poll(api_tx, event_hub) {
let events = match subscription.poll_batch(api_tx, event_hub) {
Ok(events) => events,
Err(error) => {
write_json_line_allow_disconnect(
&mut stream,
&ErrorResponse {
id: request_id,
error,
},
)?;
return Ok(());
}
};
for event in events {
if should_stop_connection(&mut stream, running)? {
return Ok(());
}
if let Err(err) = write_json_line(&mut stream, &event) {
if is_connection_closed_error(&err) {
return Ok(());
+355
View File
@@ -0,0 +1,355 @@
use super::*;
use crate::api::schema::{
AgentStatus, EventData, EventEnvelope, EventKind, PaneInfo, PaneReadResult, ReadFormat,
ReadSource,
};
use crate::ipc::{poll_local_stream_read_count, LocalStreamReadCount};
use interprocess::local_socket::traits::Listener as _;
use serde_json::{json, Value};
use std::sync::atomic::AtomicU64;
use tokio::sync::mpsc;
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(2);
struct SocketTest {
hub: EventHub,
running: Arc<AtomicBool>,
api_tx: ApiRequestSender,
api_rx: mpsc::UnboundedReceiver<ApiRequestMessage>,
workers: Vec<std::thread::JoinHandle<io::Result<()>>>,
paths: Vec<PathBuf>,
}
impl SocketTest {
fn new() -> Self {
let (api_tx, api_rx) = mpsc::unbounded_channel();
Self {
hub: EventHub::default(),
running: Arc::new(AtomicBool::new(true)),
api_tx,
api_rx,
workers: Vec::new(),
paths: Vec::new(),
}
}
fn connect(&mut self) -> Client {
static NEXT_SOCKET: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"herdr-sub-{}-{}",
std::process::id(),
NEXT_SOCKET.fetch_add(1, Ordering::Relaxed)
));
let listener = bind_local_listener(&path).unwrap();
self.paths.push(path.clone());
let mut stream = crate::ipc::connect_local_stream(&path).unwrap();
let server = listener.accept().unwrap();
set_local_stream_polling(&mut stream, true).unwrap();
let api_tx = self.api_tx.clone();
let hub = self.hub.clone();
let running = Arc::clone(&self.running);
let worker =
std::thread::spawn(move || handle_connection(server, &api_tx, &hub, &running, None));
self.workers.push(worker);
Client {
stream,
buffered: Vec::new(),
}
}
fn app_request(&mut self) -> ApiRequestMessage {
let deadline = Instant::now() + RESPONSE_TIMEOUT;
loop {
match self.api_rx.try_recv() {
Ok(request) => return request,
Err(mpsc::error::TryRecvError::Empty) => {
assert!(
Instant::now() < deadline,
"timed out waiting for app request"
);
std::thread::sleep(Duration::from_millis(1));
}
Err(error) => panic!("app request channel closed: {error}"),
}
}
}
}
impl Drop for SocketTest {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
self.api_rx.close();
while self.api_rx.try_recv().is_ok() {}
let deadline = Instant::now() + APP_RESPONSE_TIMEOUT + Duration::from_secs(1);
let mut failures = Vec::new();
for worker in self.workers.drain(..) {
while !worker.is_finished() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
}
if worker.is_finished() {
match worker.join() {
Ok(Ok(())) => {}
Ok(Err(error)) => failures.push(format!("connection failed: {error}")),
Err(_) => failures.push("connection panicked".into()),
}
} else {
failures.push("subscription connection did not stop".into());
}
}
// Also remove Windows listener marker files, including after a setup failure.
for path in self.paths.drain(..) {
if let Err(error) = std::fs::remove_file(path) {
if error.kind() != io::ErrorKind::NotFound {
failures.push(format!("socket cleanup failed: {error}"));
}
}
}
if !std::thread::panicking() {
assert!(failures.is_empty(), "{failures:?}");
}
}
}
struct Client {
stream: LocalStream,
buffered: Vec<u8>,
}
impl Client {
fn send(&mut self, request: Value) {
writeln!(self.stream, "{request}").unwrap();
}
fn subscribe(&mut self, id: &str, subscriptions: Value) {
self.send(json!({
"id": id,
"method": "events.subscribe",
"params": {"subscriptions": subscriptions}
}));
}
fn next_line(&mut self, deadline: Instant) -> Option<Value> {
// LocalStream recv timeouts are unsupported on Windows. Use the same bounded
// nonblocking/PeekNamedPipe reads as the API client, retaining partial JSON lines.
loop {
if let Some(end) = self.buffered.iter().position(|byte| *byte == b'\n') {
let line: Vec<_> = self.buffered.drain(..=end).collect();
return Some(serde_json::from_slice(&line).expect("subscription JSON"));
}
assert!(
Instant::now() < deadline,
"timed out waiting for socket response"
);
let mut bytes = [0; 4096];
match poll_local_stream_read_count(&mut self.stream, &mut bytes).unwrap() {
LocalStreamReadCount::Data(count) => {
self.buffered.extend_from_slice(&bytes[..count])
}
LocalStreamReadCount::Pending => std::thread::sleep(Duration::from_millis(1)),
LocalStreamReadCount::Closed => {
assert!(self.buffered.is_empty(), "incomplete JSON at EOF");
return None;
}
}
}
}
fn response(&mut self) -> Value {
self.next_line(Instant::now() + RESPONSE_TIMEOUT)
.expect("socket response before EOF")
}
fn assert_started(&mut self, id: &str) {
let response = self.response();
assert_eq!(response["id"], id);
assert_eq!(response["result"]["type"], "subscription_started");
}
fn assert_renames(&mut self, indices: std::ops::Range<usize>, deadline: Instant) {
for index in indices {
let event = self.next_line(deadline).expect("rename before EOF");
assert_eq!(event["event"], "workspace_renamed");
assert_eq!(event["data"]["label"], format!("flood-{index}"));
}
}
fn assert_history_lost(&mut self, id: &str) {
let response = self.response();
assert_eq!(response["id"], id);
assert_eq!(response["error"]["code"], "events_lost", "{response}");
assert_eq!(self.next_line(Instant::now() + RESPONSE_TIMEOUT), None);
}
}
fn renamed_event(index: usize) -> EventEnvelope {
EventEnvelope {
event: EventKind::WorkspaceRenamed,
data: EventData::WorkspaceRenamed {
workspace_id: "workspace_1".into(),
label: format!("flood-{index}"),
},
}
}
fn output_subscription() -> Value {
json!({
"type": "pane.output_matched",
"pane_id": "pane_1",
"source": "recent",
"match": {"type": "substring", "value": "never"}
})
}
fn reply(request: ApiRequestMessage, result: ResponseResult) {
request
.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: request.request.id,
result,
})
.unwrap(),
)
.unwrap();
}
fn reply_to_probe(request: ApiRequestMessage) {
let result = match request.request.method {
Method::PaneGet(_) => ResponseResult::PaneInfo {
pane: PaneInfo {
pane_id: "pane_1".into(),
terminal_id: "term_1".into(),
workspace_id: "workspace_1".into(),
tab_id: "tab_1".into(),
focused: true,
cwd: None,
foreground_cwd: None,
label: None,
agent: Some("pi".into()),
title: None,
terminal_title: None,
terminal_title_stripped: None,
display_agent: None,
agent_status: AgentStatus::Working,
state_labels: Default::default(),
tokens: Default::default(),
agent_session: None,
scroll: None,
revision: 0,
},
},
Method::PaneRead(_) => ResponseResult::PaneRead {
read: PaneReadResult {
pane_id: "pane_1".into(),
workspace_id: "workspace_1".into(),
tab_id: "tab_1".into(),
source: ReadSource::RecentUnwrapped,
format: ReadFormat::Text,
text: String::new(),
revision: 0,
truncated: false,
},
},
ref other => panic!("unexpected subscription probe: {other:?}"),
};
reply(request, result);
}
#[test]
fn subscriptions_drain_retained_bursts_without_per_event_poll_delay() {
let mut test = SocketTest::new();
let mut client = test.connect();
client.subscribe("burst", json!([{"type": "workspace.renamed"}]));
client.assert_started("burst");
for index in 0..128 {
test.hub.push(renamed_event(index));
}
// One deadline for the entire batch detects a 100 ms delay per event.
client.assert_renames(0..128, Instant::now() + RESPONSE_TIMEOUT);
}
#[test]
fn subscriptions_report_history_loss_before_sending_a_partial_stream() {
assert_subscription_history_loss(false);
}
#[test]
fn subscriptions_report_history_loss_before_initial_agent_status() {
assert_subscription_history_loss(true);
}
fn assert_subscription_history_loss(agent_status: bool) {
let mut test = SocketTest::new();
let mut client = test.connect();
let subscriptions = if agent_status {
json!([{
"type": "pane.agent_status_changed",
"pane_id": "pane_1",
"agent_status": "working"
}])
} else {
json!([{"type": "workspace.renamed"}, output_subscription()])
};
client.subscribe("history-gap", subscriptions);
// Hold the setup probe after the server pins its subscription cursor.
let probe = test.app_request();
assert!(probe.request.id.ends_with(":probe"));
for index in 0..600 {
test.hub.push(renamed_event(index));
}
reply_to_probe(probe);
client.assert_started("history-gap");
client.assert_history_lost("history-gap");
}
#[test]
fn lagging_subscription_closes_without_interrupting_other_clients() {
let mut test = SocketTest::new();
let mut healthy = test.connect();
healthy.subscribe("healthy", json!([{"type": "workspace.renamed"}]));
healthy.assert_started("healthy");
let mut slow = test.connect();
slow.subscribe(
"slow",
json!([{"type": "workspace.renamed"}, output_subscription()]),
);
let probe = test.app_request();
assert_eq!(probe.request.id, "slow:sub:1:probe");
reply_to_probe(probe);
slow.assert_started("slow");
// Pause only this connection in an existing app request, rather than depending
// on OS socket buffer sizes or sleeping to make its event cursor fall behind.
let paused_read = test.app_request();
assert_eq!(paused_read.request.id, "slow:sub:1:read");
assert!(matches!(paused_read.request.method, Method::PaneRead(_)));
// All five batches must finish before the held app request can time out.
let deadline = Instant::now() + RESPONSE_TIMEOUT;
for batch in 0..5 {
let indices = batch * 128..(batch + 1) * 128;
for index in indices.clone() {
test.hub.push(renamed_event(index));
}
healthy.assert_renames(indices, deadline);
}
reply_to_probe(paused_read);
slow.assert_history_lost("slow");
test.hub.push(renamed_event(640));
healthy.assert_renames(640..641, Instant::now() + RESPONSE_TIMEOUT);
let mut ordinary = test.connect();
ordinary.send(json!({"id": "ordinary", "method": "workspace.list", "params": {}}));
let request = test.app_request();
assert_eq!(request.request.id, "ordinary");
assert!(matches!(request.request.method, Method::WorkspaceList(_)));
reply(
request,
ResponseResult::WorkspaceList {
workspaces: Vec::new(),
},
);
let response = ordinary.response();
assert_eq!(response["id"], "ordinary");
assert_eq!(response["result"]["type"], "workspace_list");
}
+213 -42
View File
@@ -1,5 +1,6 @@
use regex::Regex;
use crate::api::event_hub::EventHistoryError;
use crate::api::schema::{
ErrorBody, ErrorResponse, EventKind, Method, PaneAgentStatusChangedEvent,
PaneOutputMatchedEvent, PaneScrollChangedEvent, PaneScrollInfo, Request, Subscription,
@@ -277,6 +278,72 @@ impl ActiveSubscription {
_ => Ok(self.poll(api_tx, event_hub)),
}
}
pub(super) fn poll_batch(
&mut self,
api_tx: &ApiRequestSender,
event_hub: &EventHub,
) -> Result<Vec<serde_json::Value>, ErrorBody> {
match self {
Self::Event(subscription) => {
let events = subscription_events_after(event_hub, subscription.last_sequence)?;
let mut matching = Vec::new();
for (sequence, event) in events {
subscription.last_sequence = sequence;
if event.event == subscription.event_kind {
matching.push(serde_json::to_value(event).map_err(event_encoding_error)?);
}
}
Ok(matching)
}
Self::AgentStatusChanged(subscription) => {
let events = subscription_events_after(event_hub, subscription.last_sequence)?;
let mut matching = Vec::new();
for (sequence, event) in events {
subscription.last_sequence = sequence;
if let Some(event) = subscription.event_from_history(event) {
matching.push(serde_json::to_value(event).map_err(event_encoding_error)?);
}
}
if matching.is_empty() {
if let Some(event) =
subscription.poll_snapshot(api_tx, event_hub).ok().flatten()
{
matching.push(serde_json::to_value(event).map_err(event_encoding_error)?);
}
}
Ok(matching)
}
// These subscriptions sample current state, not retained event history.
// Keep their existing cadence even when a lifecycle batch was nonempty.
Self::OutputMatched(_) | Self::ScrollChanged(_) => {
Ok(self.poll(api_tx, event_hub).into_iter().collect())
}
}
}
}
fn subscription_events_after(
event_hub: &EventHub,
sequence: u64,
) -> Result<Vec<(u64, crate::api::schema::EventEnvelope)>, ErrorBody> {
event_hub.events_after_checked(sequence).map_err(|error| match error {
EventHistoryError::Lost => ErrorBody {
code: "events_lost".into(),
message: "event subscription fell behind retained history; resubscribe and resync with session.snapshot".into(),
},
EventHistoryError::Unavailable => ErrorBody {
code: "server_unavailable".into(),
message: "event history is unavailable".into(),
},
})
}
fn event_encoding_error(error: serde_json::Error) -> ErrorBody {
ErrorBody {
code: "internal_error".into(),
message: format!("failed to encode subscription event: {error}"),
}
}
impl ActiveEventSubscription {
@@ -341,10 +408,55 @@ impl ActiveAgentStatusChangedSubscription {
api_tx: &ApiRequestSender,
event_hub: &EventHub,
) -> Result<Option<SubscriptionEventEnvelope>, ErrorResponse> {
let mut saw_status_event = false;
for (sequence, event) in event_hub.events_after(self.last_sequence) {
self.last_sequence = sequence;
let crate::api::schema::EventData::PaneAgentStatusChanged {
if let Some(event) = self.event_from_history(event) {
return Ok(Some(event));
}
}
self.poll_snapshot(api_tx, event_hub)
}
fn event_from_history(
&mut self,
event: crate::api::schema::EventEnvelope,
) -> Option<SubscriptionEventEnvelope> {
if event.event != EventKind::PaneAgentStatusChanged {
return None;
}
let crate::api::schema::EventData::PaneAgentStatusChanged {
pane_id,
workspace_id,
agent_status,
agent,
title,
display_agent,
state_labels,
} = event.data
else {
return None;
};
if pane_id != self.pane_id {
return None;
}
self.last_status = Some(agent_status);
self.last_presentation = Some(PanePresentationSnapshot::from_event(
&title,
&display_agent,
&state_labels,
));
self.initial_event = None;
if self
.status_filter
.is_some_and(|wanted| wanted != agent_status)
{
return None;
}
Some(SubscriptionEventEnvelope {
event: SubscriptionEventKind::PaneAgentStatusChanged,
data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent {
pane_id,
workspace_id,
agent_status,
@@ -352,47 +464,16 @@ impl ActiveAgentStatusChangedSubscription {
title,
display_agent,
state_labels,
} = event.data
else {
continue;
};
if event.event != crate::api::schema::EventKind::PaneAgentStatusChanged {
continue;
}
if pane_id != self.pane_id {
continue;
}
saw_status_event = true;
}),
})
}
let current_presentation =
PanePresentationSnapshot::from_event(&title, &display_agent, &state_labels);
self.last_status = Some(agent_status);
self.last_presentation = Some(current_presentation);
if self
.status_filter
.is_some_and(|wanted| wanted != agent_status)
{
continue;
}
self.initial_event = None;
return Ok(Some(SubscriptionEventEnvelope {
event: SubscriptionEventKind::PaneAgentStatusChanged,
data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent {
pane_id,
workspace_id,
agent_status,
agent,
title,
display_agent,
state_labels,
}),
}));
}
if saw_status_event {
self.initial_event = None;
} else if event_hub.current_sequence() != self.last_sequence {
fn poll_snapshot(
&mut self,
api_tx: &ApiRequestSender,
event_hub: &EventHub,
) -> Result<Option<SubscriptionEventEnvelope>, ErrorResponse> {
if event_hub.current_sequence() != self.last_sequence {
return Ok(None);
} else if let Some(event) = self.initial_event.take() {
return Ok(Some(SubscriptionEventEnvelope {
@@ -688,6 +769,96 @@ mod tests {
));
}
#[test]
fn lifecycle_batch_drains_in_order_and_advances_past_unmatched_events() {
let event_hub = EventHub::default();
event_hub.push(workspace_focused_event("old"));
let start = event_hub.current_sequence();
event_hub.push(workspace_focused_event("setup"));
let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut subscription = ActiveSubscription::new(
Subscription::WorkspaceFocused {},
"batch",
0,
&api_tx,
&event_hub,
start,
)
.unwrap();
event_hub.push(presentation_event(None));
event_hub.push(workspace_focused_event("live"));
let events = subscription.poll_batch(&api_tx, &event_hub).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0]["data"]["workspace_id"], "setup");
assert_eq!(events[1]["data"]["workspace_id"], "live");
assert!(subscription
.poll_batch(&api_tx, &event_hub)
.unwrap()
.is_empty());
let ActiveSubscription::Event(subscription) = subscription else {
panic!("expected lifecycle subscription");
};
assert_eq!(subscription.last_sequence, event_hub.current_sequence());
}
#[test]
fn agent_status_batch_preserves_transitions_filters_and_initial_state_ordering() {
for filtered in [false, true] {
let event_hub = EventHub::default();
let mut subscription = ActiveSubscription::AgentStatusChanged(Box::new(
ActiveAgentStatusChangedSubscription {
pane_id: "pane_1".into(),
status_filter: filtered.then_some(AgentStatus::Working),
last_status: Some(AgentStatus::Working),
last_presentation: None,
last_sequence: event_hub.current_sequence(),
initial_event: Some(PaneAgentStatusChangedEvent {
pane_id: "pane_1".into(),
workspace_id: "workspace_1".into(),
agent_status: AgentStatus::Working,
agent: Some("pi".into()),
title: Some("stale initial snapshot".into()),
display_agent: None,
state_labels: HashMap::new(),
}),
request_prefix: "batch".into(),
},
));
for (status, title) in [
(AgentStatus::Working, "started"),
(AgentStatus::Blocked, "approval"),
(AgentStatus::Idle, "finished"),
(AgentStatus::Working, "restarted"),
] {
let mut event = presentation_event(Some(title));
let EventData::PaneAgentStatusChanged { agent_status, .. } = &mut event.data else {
panic!("expected status data");
};
*agent_status = status;
event_hub.push(event);
}
let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel();
let events = subscription.poll_batch(&api_tx, &event_hub).unwrap();
let titles = events
.iter()
.map(|event| event["data"]["title"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(
titles,
if filtered {
vec!["started", "restarted"]
} else {
vec!["started", "approval", "finished", "restarted"]
}
);
let ActiveSubscription::AgentStatusChanged(subscription) = subscription else {
panic!("expected agent subscription");
};
assert_eq!(subscription.last_sequence, event_hub.current_sequence());
assert!(subscription.initial_event.is_none());
}
}
#[test]
fn scroll_subscription_emits_when_scroll_snapshot_changes() {
let at_bottom = PaneScrollInfo {
+106 -4
View File
@@ -597,10 +597,10 @@ impl App {
let canonical_checkout = crate::worktree::canonical_or_original(checkout_path);
let checkout_key = canonical_checkout.display().to_string();
self.state.workspaces.iter().position(|ws| {
if ws.worktree_space().is_some_and(|space| {
crate::worktree::canonical_or_original(&space.checkout_path) == canonical_checkout
}) {
return true;
if let Some(space) = ws.worktree_space() {
// Explicit checkout provenance must not be overridden by shell navigation.
return crate::worktree::canonical_or_original(&space.checkout_path)
== canonical_checkout;
}
let git_space = ws.git_space().cloned().or_else(|| {
@@ -1484,6 +1484,108 @@ mod tests {
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn api_worktree_open_preserves_explicit_membership_after_shell_cd() {
let repo = create_committed_repo("api-worktree-membership-repo");
let checkout = unique_temp_path("api-worktree-membership-checkout");
run_git(
&repo,
&[
"worktree",
"add",
"--quiet",
"-b",
"worktree/membership",
checkout.to_str().unwrap(),
"HEAD",
],
);
// Use Git's path spelling, as worktree create/open do when assigning membership.
let checkout = crate::worktree::list_existing_worktrees(&repo, false)
.unwrap()
.into_iter()
.find(|entry| entry.branch.as_deref() == Some("worktree/membership"))
.unwrap()
.path;
let mut app = app_with_parent(&repo);
let parent_id = app.state.workspaces[0].id.clone();
let source = app
.resolve_worktree_source(Some(parent_id.clone()), None)
.unwrap_or_else(|err| panic!("{}", err.message));
app.mark_worktree_membership(&source, 0, repo.clone(), false, false);
let parent_membership = app.state.workspaces[0].worktree_space().cloned();
// Shell navigation and refreshed Git metadata must not override provenance.
let parent = &mut app.state.workspaces[0];
parent.cached_git_space = crate::workspace::git_space_metadata(&checkout);
let terminal_id = parent.tabs[0]
.terminal_id(parent.tabs[0].root_pane)
.unwrap();
app.state.terminals.get_mut(terminal_id).unwrap().cwd = checkout.clone();
assert_eq!(app.open_workspace_idx_for_checkout(&checkout), None);
let mut child = Workspace::test_new("child");
child.identity_cwd = checkout.clone();
let child_id = child.id.clone();
app.state.workspaces.push(child);
app.state.ensure_test_terminals();
app.mark_worktree_membership(&source, 1, checkout.clone(), true, false);
let child_membership = app.state.workspaces[1].worktree_space().cloned();
let response = app.handle_api_request(Request {
id: "list".into(),
method: crate::api::schema::Method::WorktreeList(WorktreeListParams {
workspace_id: Some(parent_id.clone()),
..WorktreeListParams::default()
}),
});
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
let ResponseResult::WorktreeList { worktrees, .. } = success.result else {
panic!("expected worktree_list response");
};
let entry = worktrees
.iter()
.find(|entry| entry.branch.as_deref() == Some("worktree/membership"))
.unwrap();
assert_eq!(entry.open_workspace_id.as_deref(), Some(child_id.as_str()));
let response = app.handle_api_request(Request {
id: "open".into(),
method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams {
workspace_id: Some(parent_id),
branch: Some("worktree/membership".into()),
focus: false,
..WorktreeOpenParams::default()
}),
});
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
let ResponseResult::WorktreeOpened {
workspace,
already_open,
..
} = success.result
else {
panic!("expected worktree_opened response");
};
assert!(already_open);
assert_eq!(workspace.workspace_id, child_id);
assert_eq!(app.state.workspaces.len(), 2);
assert_eq!(app.state.active, Some(0));
assert_eq!(
app.state.workspaces[0].worktree_space(),
parent_membership.as_ref()
);
assert_eq!(
app.state.workspaces[1].worktree_space(),
child_membership.as_ref()
);
app.state.assert_invariants_for_test();
let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false);
crate::worktree::run_worktree_command(&remove).unwrap();
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn api_worktree_open_label_on_already_open_checkout_emits_rename_event() {
let repo = create_committed_repo("api-worktree-open-label-repo");
+60 -25
View File
@@ -42,6 +42,8 @@ pub fn stdin_reader_loop(
host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
host_sgr_pixels_active: Arc<AtomicBool>,
host_escape_disambiguation_active: bool,
initial_host_input: Vec<u8>,
#[cfg(unix)] direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>,
#[cfg(unix)] direct_response_active: Arc<AtomicBool>,
) {
@@ -53,6 +55,7 @@ pub fn stdin_reader_loop(
host_mouse_capture_active,
host_sgr_pixels_active,
);
let _ = (host_escape_disambiguation_active, initial_host_input);
windows_stdin_reader_loop(event_tx, should_quit);
}
@@ -64,6 +67,8 @@ pub fn stdin_reader_loop(
host_cell_size_query_sent,
host_mouse_capture_active,
host_sgr_pixels_active,
host_escape_disambiguation_active,
initial_host_input,
direct_response,
direct_response_active,
);
@@ -77,6 +82,8 @@ fn unix_stdin_reader_loop(
host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
host_sgr_pixels_active: Arc<AtomicBool>,
host_escape_disambiguation_active: bool,
initial_host_input: Vec<u8>,
direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>,
direct_response_active: Arc<AtomicBool>,
) {
@@ -84,6 +91,7 @@ fn unix_stdin_reader_loop(
let mut reader = stdin.lock();
let mut scratch = [0u8; 4096];
let mut framer = crate::raw_input::RawInputByteFramer::for_host_input();
framer.set_host_escape_disambiguation_active(host_escape_disambiguation_active);
if host_color_query_sent {
framer.host_color_query_sent();
framer.enable_host_color_scheme_change_tracking();
@@ -97,6 +105,57 @@ fn unix_stdin_reader_loop(
let mut last_geometry = None;
let mut direct_filter = super::direct_graphics::InputFilter::default();
if !initial_host_input.is_empty() {
let sgr_pixels = host_sgr_pixels_active.load(Ordering::Acquire);
if sgr_pixels {
last_geometry = crate::input::mouse::HostGeometry::current();
}
let chunks = framer.push(&initial_host_input);
if !send_unix_input_chunks(
chunks,
&event_tx,
&mut pending_palette,
sgr_pixels,
last_geometry,
) {
return;
}
if (framer.has_pending_input() || !pending_palette.is_empty())
&& stdin_read_ready(
&reader,
idle_flush_timeout_ms(&framer, host_mouse_capture_active.load(Ordering::Acquire)),
) == Some(false)
{
let had_pending = framer.has_pending_input();
let chunks = framer.flush_timeout();
let held_escape = had_pending && chunks.is_empty();
if !send_unix_input_chunks(
chunks,
&event_tx,
&mut pending_palette,
sgr_pixels,
last_geometry,
) || !flush_unix_palette_input(&event_tx, &mut pending_palette)
{
return;
}
if held_escape
&& stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS)
== Some(false)
&& !send_unix_input_chunks(
framer.flush_timeout(),
&event_tx,
&mut pending_palette,
sgr_pixels,
last_geometry,
)
{
return;
}
}
pending_mode = framer.has_pending_input().then_some(sgr_pixels);
}
while !should_quit.load(Ordering::Acquire) {
if direct_filter.has_pending()
&& stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS)
@@ -593,31 +652,7 @@ fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> {
#[cfg(unix)]
fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
#[repr(C)]
struct PollFd {
fd: i32,
events: i16,
revents: i16,
}
unsafe extern "C" {
fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32;
}
const POLLIN: i16 = 0x0001;
let mut pfd = PollFd {
fd,
events: POLLIN,
revents: 0,
};
let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) };
if result < 0 {
None
} else {
Some(result > 0)
}
crate::platform::poll_fd_readable(fd, timeout_ms).ok()
}
// ---------------------------------------------------------------------------
+2
View File
@@ -9,6 +9,8 @@ pub(super) struct ClientLoopConfig {
pub(super) pixel_geometry_enabled: bool,
pub(super) pixel_geometry_fallback: bool,
pub(super) mouse_capture_active: bool,
pub(super) host_escape_disambiguation_active: bool,
pub(super) initial_host_input: Vec<u8>,
pub(super) endpoint_keybindings: bool,
pub(super) remote_image_paste_key:
Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
+14 -3
View File
@@ -79,6 +79,7 @@ fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) {
warn!(err = %err, "failed to re-assert host mouse capture");
}
}
#[cfg(windows)]
use terminal_setup::{is_ssh_session, windows_vti_input_backend_enabled};
#[cfg(test)]
@@ -179,7 +180,7 @@ fn run_client_with_mode(
let endpoint_keybindings = shell_config
.as_ref()
.is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings);
let loop_config = ClientLoopConfig {
let mut loop_config = ClientLoopConfig {
sound_config: loaded_config.config.ui.sound,
mouse_scroll_lines,
redraw_on_focus_gained,
@@ -188,6 +189,8 @@ fn run_client_with_mode(
pixel_geometry_enabled,
pixel_geometry_fallback: kitty_graphics_enabled,
mouse_capture_active: mouse_capture,
host_escape_disambiguation_active: false,
initial_host_input: Vec::new(),
endpoint_keybindings,
remote_image_paste_key,
shell_config,
@@ -278,7 +281,7 @@ fn run_client_with_mode(
// The federated shell can show connection notices without any server snapshot.
let direct_attach = attach_escape.is_some();
let terminal_guard = if direct_attach {
let mut terminal_guard = if direct_attach {
setup_direct_attach_terminal(mouse_capture)
} else {
setup_terminal(mouse_capture)
@@ -287,6 +290,9 @@ fn run_client_with_mode(
eprintln!("herdr: failed to set up terminal: {err}");
err
})?;
loop_config.host_escape_disambiguation_active =
terminal_guard.host_escape_disambiguation_active();
loop_config.initial_host_input = terminal_guard.take_buffered_host_input();
// Install a panic hook so the foreground client always restores its terminal.
let panic_restore = terminal_guard.panic_restore();
@@ -374,7 +380,7 @@ async fn run_client_loop(
initial_cell_height_px: u32,
initial_pixel_geometry_exact: bool,
should_quit: Arc<AtomicBool>,
config: ClientLoopConfig,
mut config: ClientLoopConfig,
attach_escape: Option<AttachEscapeState>,
_terminal_guard: &TerminalGuard,
) -> Result<(), ClientError> {
@@ -475,6 +481,8 @@ async fn run_client_loop(
let stdin_quit = should_quit.clone();
let stdin_mouse_capture_active = host_mouse_capture_active.clone();
let stdin_sgr_pixels_active = host_sgr_pixels_active.clone();
let stdin_escape_disambiguation_active = config.host_escape_disambiguation_active;
let stdin_initial_host_input = std::mem::take(&mut config.initial_host_input);
#[cfg(unix)]
let stdin_direct_response = state.direct_graphics_response.clone();
#[cfg(unix)]
@@ -490,6 +498,8 @@ async fn run_client_loop(
will_query_host_cell_size,
stdin_mouse_capture_active,
stdin_sgr_pixels_active,
stdin_escape_disambiguation_active,
stdin_initial_host_input,
#[cfg(unix)]
stdin_direct_response,
#[cfg(unix)]
@@ -2114,6 +2124,7 @@ async fn run_client_loop(
let (effects, notification_repaint) = shell.tick_notifications(now);
outcome.repaint |= notification_repaint
| shell.tick_copy_feedback(now)
| shell.tick_workspace_highlight(now)
| shell.tick_endpoint_error(now);
let frame = outcome
.repaint
+1 -1
View File
@@ -4,7 +4,7 @@ mod actions;
mod agent_sidebar;
mod aggregate_navigation;
mod workspace_navigation;
use workspace_navigation::WorkspaceNavigationTarget;
use workspace_navigation::{PendingWorkspaceHighlight, WorkspaceNavigationTarget};
mod composition;
mod config;
mod context_menu;
+31 -3
View File
@@ -124,6 +124,16 @@ impl ClientShellState {
outcome.repaint = true;
return;
}
if action == crate::input::KeybindAction::CloseTab {
if let Some(tab_id) = self
.snapshot
.as_deref()
.and_then(|snapshot| snapshot.focused_tab_id.clone())
{
self.request_tab_close(tab_id, outcome);
}
return;
}
if action == crate::input::KeybindAction::NewTab && self.config.prompt_new_tab_name
{
self.open_new_tab_overlay();
@@ -141,6 +151,7 @@ impl ClientShellState {
return;
}
if action == crate::input::KeybindAction::WorkspacePicker {
self.pending_workspace_highlight = None;
self.mobile_switcher_scroll = 0;
self.reveal_mobile_workspace = false;
self.mode = ClientShellMode::Navigate;
@@ -349,6 +360,19 @@ impl ClientShellState {
kind: PendingEndpointKind,
outcome: &mut ClientShellInput,
) -> bool {
let changes_focus = match &method {
crate::api::schema::Method::WorkspaceFocus(_)
| crate::api::schema::Method::TabFocus(_)
| crate::api::schema::Method::PaneFocus(_)
| crate::api::schema::Method::PaneFocusDirection(_) => true,
crate::api::schema::Method::WorkspaceCreate(params) => params.focus,
crate::api::schema::Method::TabCreate(params) => params.focus,
crate::api::schema::Method::PaneSplit(params) => params.focus,
_ => false,
};
if changes_focus {
outcome.repaint |= self.pending_workspace_highlight.take().is_some();
}
if !self.endpoint_is_online(&self.active_endpoint_id) {
let label = self.active_endpoint_label().to_owned();
outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready"));
@@ -495,6 +519,13 @@ impl ClientShellState {
self.endpoint_notice_seen.remove(&timeout_key);
}
if let Err(error) = &result {
if self
.pending_workspace_highlight
.as_ref()
.is_some_and(|pending| pending.request_id == request_id)
{
self.pending_workspace_highlight = None;
}
let code = error.code.as_deref().unwrap_or("invalid_response");
if !matches!(
code,
@@ -1019,9 +1050,6 @@ impl ClientShellState {
env: Default::default(),
}))
}
KeybindAction::CloseTab => Some(Method::TabClose(TabTarget {
tab_id: focused_tab?,
})),
KeybindAction::ClosePane => Some(Method::PaneClose(PaneTarget {
pane_id: focused_pane.clone()?,
})),
+16 -2
View File
@@ -40,6 +40,12 @@ impl ClientShellState {
.navigate_workspace_id
.as_ref()
.is_some_and(|target| self.navigation_target_valid(target));
let pending_workspace_highlight =
self.pending_workspace_highlight.as_ref().filter(|pending| {
self.mode != ClientShellMode::Navigate
&& pending.target.endpoint_id == self.active_endpoint_id
&& self.navigation_target_valid(&pending.target)
});
// A resize invalidates pane geometry, not the healthy Local workspace chrome.
let local_snapshot = self.snapshot.as_deref().filter(|_| {
self.endpoints.len() == 1
@@ -65,7 +71,8 @@ impl ClientShellState {
selected_workspace_id: self
.navigate_workspace_id
.as_ref()
.filter(|_| valid_navigation_target),
.filter(|_| valid_navigation_target)
.or_else(|| pending_workspace_highlight.map(|pending| &pending.target)),
reveal_navigation_workspace: &mut self.reveal_navigation_workspace,
dragged_workspace_id: None,
workspace_drop_indicator_row: None,
@@ -143,6 +150,12 @@ impl ClientShellState {
.navigate_workspace_id
.as_ref()
.is_some_and(|target| self.navigation_target_valid(target));
let pending_workspace_highlight =
self.pending_workspace_highlight.as_ref().filter(|pending| {
self.mode != ClientShellMode::Navigate
&& pending.target.endpoint_id == self.active_endpoint_id
&& self.navigation_target_valid(&pending.target)
});
if self.snapshot.is_none() || self.pane_surface.is_none() {
return Some(self.compose_unavailable(cols, rows));
}
@@ -200,7 +213,8 @@ impl ClientShellState {
selected_workspace_id: self
.navigate_workspace_id
.as_ref()
.filter(|_| valid_navigation_target),
.filter(|_| valid_navigation_target)
.or_else(|| pending_workspace_highlight.map(|pending| &pending.target)),
reveal_navigation_workspace: &mut self.reveal_navigation_workspace,
dragged_workspace_id,
workspace_drop_indicator_row,
+1 -1
View File
@@ -358,7 +358,7 @@ impl ClientShellState {
}
}
ClientContextMenuAction::Close => {
self.push_endpoint_method(Method::TabClose(TabTarget { tab_id }), outcome);
self.request_tab_close(tab_id, outcome);
}
_ => {}
}
+2
View File
@@ -230,6 +230,7 @@ impl ClientShellState {
endpoint_id: ClientEndpointId,
outcome: &mut ClientShellInput,
) -> bool {
self.pending_workspace_highlight = None;
self.pending_agent_reveal = None;
let online = self.endpoint_is_online(&endpoint_id);
if !online && !endpoint_id.is_local() {
@@ -255,6 +256,7 @@ impl ClientShellState {
target: ClientEndpointFocusTarget,
outcome: &mut ClientShellInput,
) -> bool {
self.pending_workspace_highlight = None;
self.pending_agent_reveal = None;
let online = self.endpoint_is_online(&endpoint_id);
if !online && !endpoint_id.is_local() {
+9 -6
View File
@@ -128,7 +128,13 @@ pub(super) fn render_collapsed(
if selected {
buffer.set_style(rect, Style::default().bg(selection_background));
} else if focused {
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
buffer.set_style(
rect,
Style::default().bg(super::sidebar::workspace_active_background(
palette,
state.selected_workspace_id.is_some(),
)),
);
}
let stale = endpoint.status != ClientEndpointStatus::Online;
let number = format!(" {}", workspace.number);
@@ -449,19 +455,16 @@ pub(super) fn render_expanded(
super::sidebar::render_workspace_rows(
buffer,
nested,
workspace,
status,
config.status_indicators,
entry,
tokens,
endpoint_active,
endpoint_active && workspace.focused,
selected,
state.selected_workspace_id.is_some(),
false,
palette,
);
if selected && palette.selection_bg == ratatui::style::Color::Reset {
buffer.set_style(nested, Style::default().bg(palette.active_row_bg));
}
if endpoint.status != ClientEndpointStatus::Online {
buffer.set_style(
rect,
+6
View File
@@ -108,6 +108,9 @@ impl ClientShellState {
}
pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) {
if endpoint_id == &self.active_endpoint_id {
self.pending_workspace_highlight = None;
}
self.retire_endpoint_notifications(endpoint_id);
if let Some(endpoint) = self
.endpoints
@@ -131,6 +134,9 @@ impl ClientShellState {
endpoint_id: &ClientEndpointId,
status: ClientEndpointStatus,
) {
if endpoint_id == &self.active_endpoint_id && status != ClientEndpointStatus::Online {
self.pending_workspace_highlight = None;
}
if let Some(endpoint) = self
.endpoints
.iter_mut()
+1
View File
@@ -645,6 +645,7 @@ impl ClientShellState {
) {
use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch};
self.pending_workspace_highlight = None;
if key.code == KeyCode::Esc
|| crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix)
{
+8 -1
View File
@@ -768,7 +768,13 @@ fn mobile_items(
palette.surface0
}
} else if endpoint.endpoint_id == active_endpoint_id && workspace.focused {
palette.surface_dim
if selected_workspace_id.is_some()
&& palette.surface0 == ratatui::style::Color::Reset
{
palette.panel_bg
} else {
palette.surface_dim
}
} else {
palette.panel_bg
};
@@ -957,6 +963,7 @@ impl ClientShellState {
{
self.mobile_switcher_scroll = 0;
self.reveal_mobile_workspace = false;
self.pending_workspace_highlight = None;
self.mode = ClientShellMode::Navigate;
self.navigate_workspace_id = self.focused_navigation_target();
outcome.repaint = true;
+1 -14
View File
@@ -1692,20 +1692,7 @@ impl ClientShellState {
match self.overlay.as_ref() {
Some(ClientShellOverlay::Rename(_)) => self.save_rename_overlay(outcome),
Some(ClientShellOverlay::ConfirmClose(_)) => {
let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take()
else {
return;
};
self.push_endpoint_method(
crate::api::schema::Method::WorkspaceClose(
crate::api::schema::WorkspaceCloseParams {
workspace_id: confirm.workspace_id,
close_group: true,
},
),
outcome,
);
outcome.repaint = true;
self.accept_close_confirmation(outcome);
}
_ => {}
}
+77 -16
View File
@@ -877,19 +877,7 @@ impl ClientShellState {
if matches!(self.overlay, Some(ClientShellOverlay::ConfirmClose(_))) {
if key.code == KeyCode::Enter {
let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else {
return;
};
self.push_endpoint_method(
crate::api::schema::Method::WorkspaceClose(
crate::api::schema::WorkspaceCloseParams {
workspace_id: confirm.workspace_id,
close_group: true,
},
),
outcome,
);
outcome.repaint = true;
self.accept_close_confirmation(outcome);
} else if key.code == KeyCode::Esc {
self.overlay = None;
self.mode = ClientShellMode::Navigate;
@@ -999,16 +987,74 @@ impl ClientShellState {
outcome.repaint = true;
}
pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) {
let Some(snapshot) = self.snapshot.as_deref() else {
pub(super) fn request_tab_close(&mut self, tab_id: String, outcome: &mut ClientShellInput) {
let workspace_id = self.snapshot.as_deref().and_then(|snapshot| {
let target = snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)?;
(self.config.confirm_close
&& !snapshot
.tabs
.iter()
.any(|tab| tab.workspace_id == target.workspace_id && tab.tab_id != tab_id))
.then(|| target.workspace_id.clone())
});
if let Some(workspace_id) = workspace_id {
if self.open_close_confirmation(workspace_id, Some(tab_id.clone())) {
outcome.repaint = true;
return;
}
}
self.push_endpoint_method(
crate::api::schema::Method::TabClose(crate::api::schema::TabTarget { tab_id }),
outcome,
);
}
pub(super) fn accept_close_confirmation(&mut self, outcome: &mut ClientShellInput) {
let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else {
return;
};
outcome.repaint = true;
let method = if let Some(target) = confirm.tab_target {
if target.workspace.endpoint_id != self.active_endpoint_id
|| !self.navigation_target_valid(&target.workspace)
|| !self.snapshot.as_deref().is_some_and(|snapshot| {
snapshot.tabs.iter().any(|tab| {
tab.tab_id == target.tab_id
&& tab.workspace_id == target.workspace.workspace_id
})
})
{
self.receive_endpoint_unavailable(
"Close target changed; try closing the tab again".into(),
);
return;
}
crate::api::schema::Method::TabClose(crate::api::schema::TabTarget {
tab_id: target.tab_id,
})
} else {
crate::api::schema::Method::WorkspaceClose(crate::api::schema::WorkspaceCloseParams {
workspace_id: confirm.workspace_id,
close_group: true,
})
};
self.push_endpoint_method(method, outcome);
}
pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) {
self.open_close_confirmation(workspace_id, None);
}
fn open_close_confirmation(&mut self, workspace_id: String, tab_id: Option<String>) -> bool {
let Some(snapshot) = self.snapshot.as_deref() else {
return false;
};
let Some(workspace) = snapshot
.workspaces
.iter()
.find(|workspace| workspace.workspace_id == workspace_id)
else {
return;
return false;
};
let group_key = workspace
.worktree
@@ -1030,6 +1076,19 @@ impl ClientShellState {
})
.unwrap_or_else(|| vec![workspace]);
let closes_group = group.len() > 1;
// Keep parent-group tab closes on the existing server confirmation path.
if tab_id.is_some() && closes_group {
return false;
}
let tab_target = if let Some(tab_id) = tab_id {
let Some(workspace) = self.navigation_target(&self.active_endpoint_id, &workspace_id)
else {
return false;
};
Some(ClientTabCloseConfirmation { tab_id, workspace })
} else {
None
};
let pane_count = group
.iter()
.map(|member| {
@@ -1053,6 +1112,7 @@ impl ClientShellState {
self.overlay = Some(ClientShellOverlay::ConfirmClose(
ClientConfirmCloseOverlay {
workspace_id,
tab_target,
title: if closes_group {
"Close worktree group?".to_owned()
} else {
@@ -1061,5 +1121,6 @@ impl ClientShellState {
detail: format!("{} — {scope}", workspace.label),
},
));
true
}
}
+33 -17
View File
@@ -4,6 +4,26 @@ use ratatui::{
widgets::{Paragraph, Widget},
};
fn workspace_selection_background(palette: &Palette) -> ratatui::style::Color {
if palette.selection_bg == ratatui::style::Color::Reset {
palette.active_row_bg
} else {
palette.selection_bg
}
}
pub(in crate::client::shell) fn workspace_active_background(
palette: &Palette,
navigating: bool,
) -> ratatui::style::Color {
// The fallback cursor shares the active-row color; only fill the cursor while navigating.
if navigating && palette.selection_bg == ratatui::style::Color::Reset {
palette.sidebar_bg
} else {
palette.active_row_bg
}
}
pub(in crate::client::shell) fn collapsed_sidebar_sections(
area: Rect,
) -> (Rect, Option<u16>, Rect) {
@@ -33,6 +53,8 @@ pub(crate) fn render_collapsed_sidebar(
hits: &mut ShellHitMap,
) {
let palette = &config.palette;
let selection_background = workspace_selection_background(palette);
let active_background = workspace_active_background(palette, selected_workspace_id.is_some());
render_sidebar_background(buffer, area, palette);
let (workspace_area, divider_y, detail_area) = collapsed_sidebar_sections(area);
for (index, workspace) in snapshot
@@ -48,23 +70,17 @@ pub(crate) fn render_collapsed_sidebar(
1,
);
let selected = selected_workspace_id == Some(workspace.workspace_id.as_str());
let selection_background =
if workspace.focused && palette.selection_bg == ratatui::style::Color::Reset {
palette.active_row_bg
} else {
palette.selection_bg
};
if selected {
buffer.set_style(rect, Style::default().bg(selection_background));
} else if workspace.focused {
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
buffer.set_style(rect, Style::default().bg(active_background));
}
let number_style = if selected {
Style::default()
.fg(palette.overlay1)
.bg(selection_background)
} else if workspace.focused {
Style::default().fg(palette.text).bg(palette.active_row_bg)
Style::default().fg(palette.text).bg(active_background)
} else {
Style::default().fg(palette.overlay0)
};
@@ -308,13 +324,13 @@ pub(crate) fn render_sidebar(
render_workspace_rows(
buffer,
rect,
workspace,
status,
config.status_indicators,
entry,
rows,
true,
workspace.focused,
selected,
state.selected_workspace_id.is_some(),
dragged,
palette,
);
@@ -639,13 +655,13 @@ pub(in crate::client::shell) fn workspace_rows(
pub(in crate::client::shell) fn render_workspace_rows(
buffer: &mut Buffer,
area: Rect,
workspace: &ClientShellWorkspace,
status: crate::api::schema::AgentStatus,
indicators: crate::config::StatusIndicatorStyle,
entry: &WorkspaceEntry,
rows: Vec<Vec<crate::ui::ResolvedToken>>,
endpoint_active: bool,
focused: bool,
selected: bool,
navigating: bool,
dragged: bool,
palette: &Palette,
) {
@@ -680,7 +696,7 @@ pub(in crate::client::shell) fn render_workspace_rows(
} else {
x = x.saturating_add(3);
}
let highlighted = endpoint_active && workspace.focused || dragged;
let highlighted = focused || dragged;
let workspace_style = Style::default()
.fg(if highlighted {
palette.text
@@ -692,7 +708,7 @@ pub(in crate::client::shell) fn render_workspace_rows(
} else {
Modifier::empty()
});
let secondary_style = Style::default().fg(if endpoint_active && workspace.focused {
let secondary_style = Style::default().fg(if focused {
palette.mauve
} else {
palette.overlay0
@@ -717,11 +733,11 @@ pub(in crate::client::shell) fn render_workspace_rows(
}
let background = if selected {
Some(palette.selection_bg)
Some(workspace_selection_background(palette))
} else if dragged {
Some(palette.surface1)
} else if endpoint_active && workspace.focused {
Some(palette.active_row_bg)
} else if focused {
Some(workspace_active_background(palette, navigating))
} else {
None
};
+11
View File
@@ -562,9 +562,16 @@ pub(super) struct ClientContextMenuItem {
pub(super) action: ClientContextMenuAction,
}
#[derive(Debug)]
pub(super) struct ClientTabCloseConfirmation {
pub(super) tab_id: String,
pub(super) workspace: WorkspaceNavigationTarget,
}
#[derive(Debug)]
pub(super) struct ClientConfirmCloseOverlay {
pub(super) workspace_id: String,
pub(super) tab_target: Option<ClientTabCloseConfirmation>,
pub(super) title: String,
pub(super) detail: String,
}
@@ -880,6 +887,7 @@ pub(crate) struct ClientShellState {
pub(super) collapsed_endpoints: HashSet<ClientEndpointId>,
pub(super) mode: ClientShellMode,
pub(super) navigate_workspace_id: Option<WorkspaceNavigationTarget>,
pub(super) pending_workspace_highlight: Option<PendingWorkspaceHighlight>,
pub(super) reveal_navigation_workspace: bool,
pub(super) overlay: Option<ClientShellOverlay>,
pub(super) previous_pane_id: Option<String>,
@@ -1043,6 +1051,7 @@ impl ClientShellState {
collapsed_endpoints: HashSet::new(),
mode: ClientShellMode::Terminal,
navigate_workspace_id: None,
pending_workspace_highlight: None,
reveal_navigation_workspace: false,
overlay,
previous_pane_id: None,
@@ -1233,6 +1242,7 @@ impl ClientShellState {
self.endpoint_error = None;
self.endpoint_error_deadline = None;
self.navigate_workspace_id = None;
self.pending_workspace_highlight = None;
self.overlay = self
.config
.startup_onboarding
@@ -1549,6 +1559,7 @@ impl ClientShellState {
}
}
self.snapshot = Some(snapshot);
self.reconcile_pending_workspace_highlight();
let pending_surface = self.pending_pane_surface.take();
if let Some(surface) = pending_surface {
let matching = self.snapshot.as_ref().is_some_and(|snapshot| {
+241
View File
@@ -0,0 +1,241 @@
use super::*;
use crate::api::schema::Method;
use crossterm::event::{MouseButton, MouseEventKind};
fn close_state(confirm: bool, tab_count: usize) -> ClientShellState {
let mut projected = snapshot();
for number in 2..=tab_count {
let mut tab = projected.tabs[0].clone();
tab.tab_id = format!("tab_{number}");
tab.number = number;
tab.focused = false;
projected.tabs.push(tab);
}
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.config.confirm_close = confirm;
state.set_snapshot(Box::new(projected));
state.set_pane_surface(surface());
state.compose(106, 24).unwrap();
state
}
fn click(state: &mut ClientShellState, rect: Rect) -> ClientShellInput {
state.handle_raw_events(vec![crate::raw_input::RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: rect.x,
row: rect.y,
modifiers: KeyModifiers::empty(),
})])
}
fn request_close(state: &mut ClientShellState, menu: bool) -> ClientShellInput {
if menu {
state.open_tab_context_menu("tab_1".into(), 30, 1);
state.compose(106, 24).unwrap();
let close_row = state.hits.context_menu_rows[2].0;
click(state, close_row)
} else {
let mut outcome = ClientShellInput::default();
state.record_binding(
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseTab),
&mut outcome,
);
outcome
}
}
fn assert_no_close(outcome: &ClientShellInput) {
assert!(outcome.requests.is_empty());
assert!(outcome.actions.iter().all(|action| {
!matches!(action, ClientShellAction::Endpoint { request, .. }
if matches!(request.method, Method::TabClose(_) | Method::WorkspaceClose(_)))
}));
}
fn assert_tab_close(outcome: &ClientShellInput) {
let methods = outcome
.actions
.iter()
.filter_map(|action| match action {
ClientShellAction::Endpoint { request, .. } => Some(&request.method),
_ => None,
})
.filter(|method| !matches!(method, Method::TabFocus(_)))
.collect::<Vec<_>>();
assert!(matches!(methods.as_slice(), [Method::TabClose(target)] if target.tab_id == "tab_1"));
}
#[test]
fn last_tab_close_waits_for_keyboard_or_mouse_confirmation() {
for menu in [false, true] {
let mut state = close_state(true, 1);
let requested = request_close(&mut state, menu);
assert_no_close(&requested);
assert!(requested.repaint);
assert!(matches!(
state.overlay,
Some(ClientShellOverlay::ConfirmClose(_))
));
let frame = state.compose(106, 24).unwrap();
let text = frame_rows(&frame).join("\n");
assert!(text.contains("Close workspace?"));
assert!(text.contains("1 pane"));
let accepted = if menu {
let primary = state.hits.overlay_primary;
click(&mut state, primary)
} else {
state.handle_input_bytes(b"\r")
};
assert_tab_close(&accepted);
assert!(state.overlay.is_none());
}
}
#[test]
fn last_tab_close_confirmation_can_be_cancelled() {
for mouse in [false, true] {
let mut state = close_state(true, 1);
assert_no_close(&request_close(&mut state, false));
assert!(matches!(
state.overlay,
Some(ClientShellOverlay::ConfirmClose(_))
));
state.compose(106, 24).unwrap();
let cancelled = if mouse {
let cancel = state.hits.overlay_cancel;
click(&mut state, cancel)
} else {
state.handle_input_bytes(b"\x1b")
};
assert_no_close(&cancelled);
assert!(state.overlay.is_none());
}
}
#[test]
fn tab_close_stays_immediate_with_confirmation_disabled_or_other_tabs() {
for (confirm, tabs) in [(false, 1), (false, 2), (true, 2)] {
for menu in [false, true] {
let mut state = close_state(confirm, tabs);
assert_tab_close(&request_close(&mut state, menu));
assert!(state.overlay.is_none());
}
}
}
#[test]
fn last_tab_confirmation_preserves_target_across_focus_changes_and_new_tabs() {
let mut state = close_state(true, 1);
let mut projected = state.snapshot.as_deref().unwrap().clone();
let mut other_workspace = projected.workspaces[0].clone();
other_workspace.workspace_id = "ws_2".into();
other_workspace.active_tab_id = "other_tab".into();
other_workspace.focused = false;
projected.workspaces.push(other_workspace);
let mut other_tab = projected.tabs[0].clone();
other_tab.workspace_id = "ws_2".into();
other_tab.tab_id = "other_tab".into();
other_tab.focused = false;
projected.tabs.push(other_tab);
state.set_snapshot(Box::new(projected.clone()));
assert_no_close(&request_close(&mut state, false));
assert!(matches!(
state.overlay,
Some(ClientShellOverlay::ConfirmClose(_))
));
let mut new_tab = projected.tabs[0].clone();
new_tab.tab_id = "new_tab".into();
new_tab.focused = false;
projected.tabs.push(new_tab);
projected.focused_workspace_id = Some("ws_2".into());
projected.focused_tab_id = Some("other_tab".into());
for workspace in &mut projected.workspaces {
workspace.focused = workspace.workspace_id == "ws_2";
}
for tab in &mut projected.tabs {
tab.focused = tab.tab_id == "other_tab";
}
state.set_snapshot(Box::new(projected));
assert_tab_close(&state.handle_input_bytes(b"\r"));
}
#[test]
fn last_tab_confirmation_rejects_missing_moved_or_reconnected_targets() {
for change in ["missing", "moved", "reconnected"] {
let mut state = close_state(true, 1);
assert_no_close(&request_close(&mut state, false));
assert!(matches!(
state.overlay,
Some(ClientShellOverlay::ConfirmClose(_))
));
let mut projected = state.snapshot.as_deref().unwrap().clone();
match change {
"missing" => projected.tabs.clear(),
"moved" => projected.tabs[0].workspace_id = "different_workspace".into(),
"reconnected" => state.endpoints[0].snapshot_generation = Some(2),
_ => unreachable!(),
}
if change != "reconnected" {
state.set_snapshot(Box::new(projected));
}
assert_no_close(&state.handle_input_bytes(b"\r"));
assert!(state.overlay.is_none());
}
}
#[test]
fn last_tab_close_preserves_parent_group_and_linked_workspace_scope() {
for linked in [false, true] {
let mut state = close_state(true, 1);
let mut projected = state.snapshot.as_deref().unwrap().clone();
projected.workspaces[0].worktree = Some(ClientShellWorktree {
key: "repo".into(),
label: "repo".into(),
is_linked_worktree: linked,
});
let mut sibling = projected.workspaces[0].clone();
sibling.workspace_id = "ws_2".into();
sibling.active_tab_id = "tab_2".into();
sibling.focused = false;
sibling.worktree.as_mut().unwrap().is_linked_worktree = !linked;
projected.workspaces.push(sibling);
let mut tab = projected.tabs[0].clone();
tab.tab_id = "tab_2".into();
tab.workspace_id = "ws_2".into();
tab.focused = false;
projected.tabs.push(tab);
state.set_snapshot(Box::new(projected));
let close = request_close(&mut state, false);
if linked {
assert_no_close(&close);
assert!(matches!(
state.overlay,
Some(ClientShellOverlay::ConfirmClose(_))
));
assert_tab_close(&state.handle_input_bytes(b"\r"));
} else {
assert_tab_close(&close);
assert!(state.overlay.is_none());
let [ClientShellAction::Endpoint { request, .. }] = close.actions.as_slice() else {
panic!("tab close request");
};
state.handle_endpoint_result(
"boot-1",
&request.id,
Err(ClientShellEndpointError {
code: Some("confirmation_required".into()),
message: "closing this tab would close a worktree group".into(),
}),
);
assert!(matches!(state.overlay.as_ref(),
Some(ClientShellOverlay::ConfirmClose(confirm)) if confirm.title == "Close worktree group?"));
let accepted = state.handle_input_bytes(b"\r");
assert!(matches!(accepted.actions.as_slice(),
[ClientShellAction::Endpoint { request, .. }]
if matches!(&request.method, Method::WorkspaceClose(params)
if params.workspace_id == "ws_1" && params.close_group)));
}
}
}
+1
View File
@@ -216,6 +216,7 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
}),
ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay {
workspace_id: "ws_1".into(),
tab_target: None,
title: "close".into(),
detail: "confirm".into(),
}),
+1
View File
@@ -247,6 +247,7 @@ fn surface_with_popup() -> PaneSurfaceFrame {
mod agents_worktrees_notifications;
mod chrome_context;
mod close_tab;
mod copy;
mod endpoint_requests;
mod endpoints;
+466 -1
View File
@@ -71,6 +71,81 @@ fn workspace_rect(state: &ClientShellState, endpoint: &ClientEndpointId, workspa
.expect("visible workspace")
}
#[test]
fn local_navigation_highlight_stays_visible_with_terminal_theme() {
use ratatui::style::Color;
for compact in [false, true] {
for selection_bg in [Color::Reset, Color::Rgb(70, 63, 93)] {
let mut config = ClientShellConfig::from_config(&Config::default());
config.palette = Palette::terminal();
config.palette.selection_bg = selection_bg;
let expected_bg = if selection_bg == Color::Reset {
config.palette.active_row_bg
} else {
selection_bg
};
let mut state = ClientShellState::new(config);
state.set_snapshot(Box::new(workspaces(3)));
state.set_pane_surface(surface());
state.sidebar_collapsed = compact;
state.compose(100, 28).unwrap();
enter_navigation(&mut state);
for workspace_id in ["ws_1", "ws_2"] {
assert_selected(&state, &ClientEndpointId::Local, workspace_id);
let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap();
let selected = workspace_rect(&state, &ClientEndpointId::Local, workspace_id);
for y in selected.y..selected.bottom() {
for x in selected.x..selected.right() {
assert_eq!(
buffer[(x, y)].bg,
expected_bg,
"compact={compact}, {workspace_id}, ({x}, {y})"
);
}
}
let untouched = workspace_rect(&state, &ClientEndpointId::Local, "ws_3");
assert_ne!(buffer[(untouched.x, untouched.y)].bg, expected_bg);
if workspace_id != "ws_1" {
let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1");
assert_eq!(
buffer[(focused.x, focused.y)].bg,
if selection_bg == Color::Reset {
state.config.palette.sidebar_bg
} else {
state.config.palette.active_row_bg
}
);
assert_ne!(buffer[(focused.x, focused.y)].bg, expected_bg);
}
preview_key(&mut state, b"\x1b[B");
}
assert_eq!(
state
.snapshot
.as_ref()
.unwrap()
.focused_workspace_id
.as_deref(),
Some("ws_1")
);
preview_key(&mut state, b"\x1b");
let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap();
let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1");
assert_eq!(
buffer[(focused.x, focused.y)].bg,
state.config.palette.active_row_bg
);
let cancelled = workspace_rect(&state, &ClientEndpointId::Local, "ws_3");
assert_eq!(
buffer[(cancelled.x, cancelled.y)].bg,
state.config.palette.sidebar_bg
);
}
}
}
#[test]
fn navigation_highlights_only_the_preview_and_activates_on_enter() {
for (compact, cols) in [(true, 100), (false, 100), (false, 44)] {
@@ -113,7 +188,13 @@ fn navigation_highlights_only_the_preview_and_activates_on_enter() {
assert_ne!(buffer[(other.x + 2, other.y)].bg, color);
assert_eq!(
buffer[(focused.x + 2, focused.y)].bg,
if cols == 44 {
if terminal_theme {
if cols == 44 {
palette.panel_bg
} else {
palette.sidebar_bg
}
} else if cols == 44 {
palette.surface_dim
} else {
palette.active_row_bg
@@ -503,3 +584,387 @@ fn aggregate_navigation_reveals_overflow_and_preserves_order() {
assert_selected(&state, &ClientEndpointId::Local, "ws_1");
}
}
fn local_navigation_state(compact: bool) -> ClientShellState {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.config.palette = Palette::terminal();
state.sidebar_collapsed = compact;
state.set_snapshot(Box::new(workspaces(3)));
state.set_pane_surface(surface());
state.compose(100, 28).unwrap();
state
}
fn request_local_navigation(state: &mut ClientShellState, down: usize) -> String {
enter_navigation(state);
for _ in 0..down {
preview_key(state, b"\x1b[B");
}
let outcome = state.handle_input_bytes(b"\r");
let [ClientShellAction::Endpoint { request, .. }] = outcome.actions.as_slice() else {
panic!("expected a local workspace focus request");
};
assert!(matches!(
request.method,
crate::api::schema::Method::WorkspaceFocus(_)
));
request.id.clone()
}
fn assert_local_highlight(state: &mut ClientShellState, selected_id: &str) {
let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap();
for workspace_id in ["ws_1", "ws_2", "ws_3"] {
let rect = workspace_rect(state, &ClientEndpointId::Local, workspace_id);
assert_eq!(
(rect.x..rect.right())
.any(|x| buffer[(x, rect.y)].bg == state.config.palette.active_row_bg),
workspace_id == selected_id,
"expected only {selected_id} highlighted, checking {workspace_id}"
);
}
}
fn set_local_focus(state: &mut ClientShellState, workspace_id: &str, revision: u64) {
let mut snapshot = workspaces(3);
snapshot.revision = revision;
snapshot.focused_workspace_id = Some(workspace_id.into());
for workspace in &mut snapshot.workspaces {
workspace.focused = workspace.workspace_id == workspace_id;
}
state.set_snapshot(Box::new(snapshot));
let mut frame = surface();
frame.projection_revision = revision;
state.set_pane_surface(frame);
}
#[test]
fn accepted_local_navigation_keeps_highlight_until_authoritative_focus() {
for compact in [false, true] {
for response_first in [false, true] {
let mut state = local_navigation_state(compact);
let request_id = request_local_navigation(&mut state, 2);
assert_eq!(state.mode, ClientShellMode::Terminal);
assert!(state.navigate_workspace_id.is_none());
assert_eq!(
state
.snapshot
.as_ref()
.unwrap()
.focused_workspace_id
.as_deref(),
Some("ws_1")
);
assert_eq!(state.focused_pane_id().as_deref(), Some("pane_1"));
assert_local_highlight(&mut state, "ws_3");
state.invalidate_pane_surface();
assert_local_highlight(&mut state, "ws_3");
state.set_pane_surface(surface());
if response_first {
state.handle_endpoint_result(
"boot-1",
&request_id,
Ok(crate::api::schema::ResponseResult::Ok {}),
);
assert_local_highlight(&mut state, "ws_3");
}
set_local_focus(&mut state, "ws_1", 2);
assert_local_highlight(&mut state, "ws_3");
set_local_focus(&mut state, "ws_3", 3);
assert_local_highlight(&mut state, "ws_3");
if !response_first {
state.handle_endpoint_result(
"boot-1",
&request_id,
Ok(crate::api::schema::ResponseResult::Ok {}),
);
}
set_local_focus(&mut state, "ws_2", 4);
assert_local_highlight(&mut state, "ws_2");
}
}
}
#[test]
fn failed_local_navigation_releases_only_its_own_highlight() {
for failure in ["rejected", "endpoint_timeout", "cancelled"] {
let mut state = local_navigation_state(false);
let request_id = request_local_navigation(&mut state, 2);
assert_local_highlight(&mut state, "ws_3");
if failure == "cancelled" {
assert!(state.cancel_endpoint_request(&request_id));
} else {
state.handle_endpoint_result(
"boot-1",
&request_id,
Err(ClientShellEndpointError {
code: Some(failure.into()),
message: "focus failed".into(),
}),
);
}
assert_local_highlight(&mut state, "ws_1");
state.handle_endpoint_result(
"boot-1",
&request_id,
Ok(crate::api::schema::ResponseResult::Ok {}),
);
assert_local_highlight(&mut state, "ws_1");
}
for old_down in [1, 2] {
let mut state = local_navigation_state(false);
let old_request = request_local_navigation(&mut state, old_down);
let latest_request = request_local_navigation(&mut state, 2);
state.cancel_endpoint_request(&old_request);
assert_local_highlight(&mut state, "ws_3");
state.cancel_endpoint_request(&latest_request);
assert_local_highlight(&mut state, "ws_1");
}
}
#[test]
fn pending_navigation_highlight_does_not_survive_identity_changes() {
for change in [
"disconnect",
"retire",
"boot",
"generation",
"deleted",
"endpoint",
] {
let mut state = local_navigation_state(false);
let request_id = request_local_navigation(&mut state, 2);
state.handle_endpoint_result(
"boot-1",
&request_id,
Ok(crate::api::schema::ResponseResult::Ok {}),
);
assert_local_highlight(&mut state, "ws_3");
let mut snapshot = workspaces(3);
match change {
"disconnect" => {
state.mark_endpoint_disconnected(&ClientEndpointId::Local);
state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online);
state.set_snapshot(Box::new(snapshot));
}
"retire" => {
state.retire_endpoint(&ClientEndpointId::Local);
state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online);
state.set_snapshot(Box::new(snapshot));
}
"boot" => {
snapshot.boot_id = "replacement-boot".into();
state.set_snapshot(Box::new(snapshot));
}
"generation" => state.set_endpoint_snapshot_for_generation(
&ClientEndpointId::Local,
1,
Box::new(snapshot),
),
"deleted" => {
snapshot.workspaces.pop();
state.set_snapshot(Box::new(snapshot));
state.set_snapshot(Box::new(workspaces(3)));
}
"endpoint" => {
let profile = remote_profile();
let remote = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&remote, ClientEndpointStatus::Online);
state.set_endpoint_snapshot(&remote, Box::new(snapshot));
assert!(state.activate_endpoint_projection(&remote));
assert!(state.pending_workspace_highlight.is_none());
assert!(state.activate_endpoint_projection(&ClientEndpointId::Local));
state.set_endpoint_catalog(&[]);
}
_ => unreachable!(),
}
assert!(state.pending_workspace_highlight.is_none(), "{change}");
let mut frame = surface();
frame.boot_id = state.snapshot.as_ref().unwrap().boot_id.clone();
state.set_pane_surface(frame);
assert_local_highlight(&mut state, "ws_1");
}
}
#[test]
fn navigation_highlight_requires_enqueued_focus_and_yields_to_new_intent() {
let mut state = local_navigation_state(false);
state.set_endpoint_methods(Some(Vec::new()));
enter_navigation(&mut state);
preview_key(&mut state, b"\x1b[B");
let refused = state.handle_input_bytes(b"\r");
assert!(refused.actions.is_empty());
assert_eq!(state.mode, ClientShellMode::Terminal);
assert!(state.pending_workspace_highlight.is_none());
// The unsupported-action notice spans the sidebar on this narrow frame.
assert!(state.visible_endpoint_notice.take().is_some());
assert_local_highlight(&mut state, "ws_1");
state.set_endpoint_methods(None);
request_local_navigation(&mut state, 2);
enter_navigation(&mut state);
preview_key(&mut state, b"\x1b");
assert_local_highlight(&mut state, "ws_1");
request_local_navigation(&mut state, 2);
let mut unrelated = ClientShellInput::default();
state.push_endpoint_method(
crate::api::schema::Method::ServerReloadConfig(crate::api::schema::EmptyParams::default()),
&mut unrelated,
);
let [ClientShellAction::Endpoint { request, .. }] = unrelated.actions.as_slice() else {
panic!("expected unrelated request");
};
state.cancel_endpoint_request(&request.id);
assert_local_highlight(&mut state, "ws_3");
let mut focus = ClientShellInput::default();
state.focus_or_activate(
ClientEndpointId::Local,
ClientEndpointFocusTarget::Workspace("ws_2".into()),
&mut focus,
);
assert!(state.pending_workspace_highlight.is_none());
}
#[test]
fn directional_pane_focus_releases_an_accepted_workspace_highlight() {
use crate::api::schema::{Method, PaneDirection, ResponseResult};
for (key, direction) in [
(b'h', PaneDirection::Left),
(b'j', PaneDirection::Down),
(b'k', PaneDirection::Up),
(b'l', PaneDirection::Right),
] {
for rejected in [false, true] {
let mut state = local_navigation_state(false);
let pending_request = request_local_navigation(&mut state, 2);
assert_local_highlight(&mut state, "ws_3");
preview_key(&mut state, &[0x02]);
let outcome = state.handle_input_bytes(&[key]);
let [ClientShellAction::Endpoint { request, .. }] = outcome.actions.as_slice() else {
panic!("expected a directional pane focus request");
};
let Method::PaneFocusDirection(params) = &request.method else {
panic!("expected PaneFocusDirection");
};
assert_eq!(params.direction, direction);
assert_eq!(params.pane_id.as_deref(), Some("pane_1"));
assert!(state.pending_workspace_highlight.is_none());
assert_local_highlight(&mut state, "ws_1");
let result = if rejected {
Err(ClientShellEndpointError {
code: Some("rejected".into()),
message: "focus rejected".into(),
})
} else {
Ok(ResponseResult::Ok {})
};
state.handle_endpoint_result("boot-1", &pending_request, result);
assert_local_highlight(&mut state, "ws_1");
}
}
}
#[test]
fn direct_agent_focus_repaints_when_releasing_a_workspace_highlight() {
let mut config = Config::default();
config.keys.focus_agent = crate::config::BindingConfig::one("ctrl+alt+1");
let mut projected = workspaces(3);
projected.agents.push(agent("agent", AgentStatus::Idle, 1));
for pending in [false, true] {
let mut state = local_navigation_state(false);
state.config.keybinds = ClientShellConfig::from_config(&config).keybinds;
state.set_snapshot(Box::new(projected.clone()));
state.compose(100, 28).unwrap();
if pending {
request_local_navigation(&mut state, 2);
assert_local_highlight(&mut state, "ws_3");
}
// Direct bindings do not inherit the repaint from leaving prefix mode.
let outcome =
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
KeyCode::Char('1'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
))]);
assert!(
matches!(outcome.actions.as_slice(), [ClientShellAction::Endpoint { request, .. }]
if matches!(&request.method, crate::api::schema::Method::PaneFocus(params)
if params.pane_id == "pane_1"))
);
assert!(state.pending_workspace_highlight.is_none());
assert_eq!(outcome.repaint, pending);
assert_local_highlight(&mut state, "ws_1");
}
}
#[test]
fn cancelled_close_does_not_restore_an_older_navigation_highlight() {
let mut state = local_navigation_state(false);
request_local_navigation(&mut state, 2);
state.open_confirm_close_overlay("ws_1".into());
preview_key(&mut state, b"\x1b");
assert_eq!(state.mode, ClientShellMode::Navigate);
preview_key(&mut state, b"\x1b");
assert_eq!(state.mode, ClientShellMode::Terminal);
assert_local_highlight(&mut state, "ws_1");
}
#[test]
fn coalesced_navigation_focus_does_not_leave_a_permanent_highlight() {
let mut state = local_navigation_state(false);
let before_request = std::time::Instant::now();
let request_id = request_local_navigation(&mut state, 2);
state.handle_endpoint_result(
"boot-1",
&request_id,
Ok(crate::api::schema::ResponseResult::Ok {}),
);
// Another client can focus the original workspace before the server projects
// either change, so a successful request need not produce a new snapshot.
assert!(!state.tick_workspace_highlight(before_request));
assert_local_highlight(&mut state, "ws_3");
let now = std::time::Instant::now();
assert!(state.tick_workspace_highlight(now + std::time::Duration::from_secs(2)));
assert_local_highlight(&mut state, "ws_1");
assert!(!state.tick_workspace_highlight(now + std::time::Duration::from_secs(3)));
}
#[test]
fn navigation_highlight_ends_for_noop_focus_and_focused_creation() {
let mut state = local_navigation_state(false);
request_local_navigation(&mut state, 0);
assert!(state.pending_workspace_highlight.is_none());
set_local_focus(&mut state, "ws_2", 2);
assert_local_highlight(&mut state, "ws_2");
for focus in [false, true] {
for method in [
crate::api::schema::Method::WorkspaceCreate(
crate::api::schema::WorkspaceCreateParams {
source_workspace_id: None,
cwd: None,
focus,
label: None,
env: Default::default(),
},
),
crate::api::schema::Method::TabCreate(crate::api::schema::TabCreateParams {
workspace_id: Some("ws_1".into()),
cwd: None,
focus,
label: None,
env: Default::default(),
}),
] {
let mut state = local_navigation_state(false);
request_local_navigation(&mut state, 2);
let mut outcome = ClientShellInput::default();
state.push_endpoint_method(method, &mut outcome);
assert_eq!(state.pending_workspace_highlight.is_none(), focus);
assert_local_highlight(&mut state, if focus { "ws_1" } else { "ws_3" });
}
}
}
+52 -2
View File
@@ -9,6 +9,13 @@ pub(super) struct WorkspaceNavigationTarget {
generation: Option<u64>,
}
/// Display-only continuity while a direct focus request awaits its authoritative snapshot.
pub(super) struct PendingWorkspaceHighlight {
pub(super) target: WorkspaceNavigationTarget,
pub(super) request_id: String,
expires_at: std::time::Instant,
}
impl WorkspaceNavigationTarget {
pub(super) fn matches(&self, endpoint_id: &ClientEndpointId, workspace_id: &str) -> bool {
&self.endpoint_id == endpoint_id && self.workspace_id == workspace_id
@@ -16,6 +23,35 @@ impl WorkspaceNavigationTarget {
}
impl ClientShellState {
pub(crate) fn tick_workspace_highlight(&mut self, now: std::time::Instant) -> bool {
if self
.pending_workspace_highlight
.as_ref()
.is_some_and(|pending| now >= pending.expires_at)
{
self.pending_workspace_highlight = None;
return true;
}
false
}
pub(super) fn reconcile_pending_workspace_highlight(&mut self) {
if self
.pending_workspace_highlight
.as_ref()
.is_some_and(|pending| {
pending.target.endpoint_id != self.active_endpoint_id
|| !self.navigation_target_valid(&pending.target)
|| self.snapshot.as_deref().is_some_and(|snapshot| {
snapshot.focused_workspace_id.as_deref()
== Some(pending.target.workspace_id.as_str())
})
})
{
self.pending_workspace_highlight = None;
}
}
pub(super) fn navigation_target(
&self,
endpoint_id: &ClientEndpointId,
@@ -140,11 +176,25 @@ impl ClientShellState {
outcome.repaint = true;
return;
}
let action_index = outcome.actions.len();
if self.focus_or_activate(
target.endpoint_id,
ClientEndpointFocusTarget::Workspace(target.workspace_id),
target.endpoint_id.clone(),
ClientEndpointFocusTarget::Workspace(target.workspace_id.clone()),
outcome,
) {
// Endpoint handoffs already retain their coherent source frame in the runtime.
// Only retain a highlight if a focus request was actually enqueued.
if let Some(ClientShellAction::Endpoint { request, .. }) =
outcome.actions.get(action_index)
{
self.pending_workspace_highlight = Some(PendingWorkspaceHighlight {
target,
request_id: request.id.clone(),
// A later focus can be coalesced with this one before a snapshot is sent.
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(1),
});
self.reconcile_pending_workspace_highlight();
}
self.mode = ClientShellMode::Terminal;
self.navigate_workspace_id = None;
}
+292 -4
View File
@@ -1,10 +1,14 @@
//! Terminal setup and restoration for the rendered client.
use std::io::{self, Write as _};
#[cfg(not(windows))]
use std::os::fd::AsRawFd as _;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[cfg(windows)]
use std::sync::{Mutex, MutexGuard};
#[cfg(not(windows))]
use std::time::{Duration, Instant};
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
@@ -44,6 +48,8 @@ pub(super) fn setup_terminal_with_capabilities(
) -> io::Result<TerminalGuard> {
ratatui::init();
let mut terminal_guard = TerminalGuard {
host_escape_disambiguation_active: false,
buffered_host_input: Vec::new(),
reset_keyboard_enhancements: false,
reset_modify_other_keys: false,
reset_host_color_scheme_reports: false,
@@ -68,22 +74,25 @@ pub(super) fn setup_terminal_with_capabilities(
WindowsVirtualTerminalInputSetup::default()
};
if enable_client_protocols {
let (host_escape_disambiguation_active, buffered_host_input) = if enable_client_protocols {
terminal_guard.reset_keyboard_enhancements = true;
push_keyboard_enhancement_flags()?;
let (active, buffered_input) = query_host_escape_disambiguation();
set_mouse_capture(mouse_capture, false)?;
execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
if host_color_scheme_reports {
terminal_guard.reset_host_color_scheme_reports = true;
write_host_color_scheme_report_mode(&mut io::stdout(), true)?;
}
terminal_guard.reset_keyboard_enhancements = true;
push_keyboard_enhancement_flags()?;
(active, buffered_input)
} else {
if should_query_host_terminal_theme() {
write_host_color_scheme_report_mode(&mut io::stdout(), false)?;
}
set_mouse_capture(mouse_capture, false)?;
execute!(io::stdout(), EnableBracketedPaste)?;
}
(false, Vec::new())
};
#[cfg(windows)]
if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session {
@@ -113,6 +122,8 @@ pub(super) fn setup_terminal_with_capabilities(
execute!(io::stdout(), DisableLineWrap)?;
terminal_guard.host_escape_disambiguation_active = host_escape_disambiguation_active;
terminal_guard.buffered_host_input = buffered_host_input;
Ok(terminal_guard)
}
@@ -122,6 +133,8 @@ pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: b
/// Guard that restores the terminal when dropped.
pub(super) struct TerminalGuard {
host_escape_disambiguation_active: bool,
buffered_host_input: Vec<u8>,
reset_keyboard_enhancements: bool,
reset_modify_other_keys: bool,
reset_host_color_scheme_reports: bool,
@@ -131,6 +144,189 @@ pub(super) struct TerminalGuard {
restore_windows_input_mode: Arc<WindowsInputModeRestore>,
}
#[cfg(not(windows))]
const HOST_KEYBOARD_QUERY_TIMEOUT: Duration = Duration::from_millis(250);
#[cfg(not(windows))]
const MAX_BUFFERED_HOST_INPUT: usize = 64 * 1024;
#[cfg(not(windows))]
#[derive(Default)]
struct HostKeyboardProbeResponses {
flags: Option<u16>,
primary_device_attributes: bool,
}
#[cfg(not(windows))]
fn query_host_escape_disambiguation() -> (bool, Vec<u8>) {
const QUERY: &[u8] = b"\x1b[?u\x1b[c";
let mut buffered_input = Vec::new();
if let Err(err) = io::stdout()
.write_all(QUERY)
.and_then(|()| io::stdout().flush())
{
tracing::debug!(%err, "host keyboard enhancement query unavailable");
return (false, buffered_input);
}
// Bypass StdinLock's shared buffer so poll and read observe the same bytes.
let stdin = io::stdin();
let stdin_fd = stdin.as_raw_fd();
let deadline = Instant::now() + HOST_KEYBOARD_QUERY_TIMEOUT;
let mut responses = HostKeyboardProbeResponses::default();
while !responses.primary_device_attributes && buffered_input.len() < MAX_BUFFERED_HOST_INPUT {
let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
break;
};
let timeout_ms = remaining.as_millis().clamp(1, i32::MAX as u128) as i32;
match crate::platform::poll_fd_readable(stdin_fd, timeout_ms) {
Ok(true) => {}
Ok(false) => break,
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => {
tracing::debug!(%err, "host keyboard enhancement query read unavailable");
break;
}
}
let mut scratch = [0u8; 4096];
let capacity = MAX_BUFFERED_HOST_INPUT - buffered_input.len();
let read_limit = capacity.min(scratch.len());
match crate::platform::read_fd(stdin_fd, &mut scratch[..read_limit]) {
Ok(0) => break,
Ok(read) => {
buffered_input.extend_from_slice(&scratch[..read]);
consume_host_keyboard_probe_responses(&mut buffered_input, &mut responses);
}
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => {
tracing::debug!(%err, "host keyboard enhancement query read failed");
break;
}
}
}
(
host_escape_disambiguation_confirmed(&responses),
buffered_input,
)
}
#[cfg(not(windows))]
fn host_escape_disambiguation_confirmed(responses: &HostKeyboardProbeResponses) -> bool {
responses.primary_device_attributes
&& responses
.flags
.is_some_and(|flags| flags & 0b0000_0001 != 0)
}
#[cfg(not(windows))]
fn consume_host_keyboard_probe_responses(
buffered_input: &mut Vec<u8>,
responses: &mut HostKeyboardProbeResponses,
) {
const PASTE_START: &[u8] = b"\x1b[200~";
const PASTE_END: &[u8] = b"\x1b[201~";
let mut offset = 0;
while offset < buffered_input.len() {
if buffered_input[offset..].starts_with(PASTE_START) {
let payload_start = offset + PASTE_START.len();
let Some(relative_end) = buffered_input[payload_start..]
.windows(PASTE_END.len())
.position(|bytes| bytes == PASTE_END)
else {
break;
};
offset = payload_start + relative_end + PASTE_END.len();
continue;
}
if let Some(control_string_end) = host_control_string_end(&buffered_input[offset..]) {
let Some(control_string_end) = control_string_end else {
break;
};
offset += control_string_end;
continue;
}
if !buffered_input[offset..].starts_with(b"\x1b[?") {
offset += 1;
continue;
}
let start = offset;
let mut end = start + 3;
while end < buffered_input.len()
&& (buffered_input[end].is_ascii_digit() || buffered_input[end] == b';')
{
end += 1;
}
if end == buffered_input.len() {
break;
}
let body = &buffered_input[start + 3..end];
let recognized = match buffered_input[end] {
b'u' if !body.is_empty() && body.iter().all(u8::is_ascii_digit) => {
std::str::from_utf8(body)
.ok()
.and_then(|flags| flags.parse().ok())
.map(|flags| {
if !responses.primary_device_attributes {
responses.flags = Some(flags);
}
})
.is_some()
}
b'c' if !body.is_empty()
&& body
.iter()
.all(|byte| byte.is_ascii_digit() || *byte == b';') =>
{
responses.primary_device_attributes = true;
true
}
_ => false,
};
if recognized {
buffered_input.drain(start..=end);
} else {
offset += 1;
}
}
}
#[cfg(not(windows))]
fn host_control_string_end(bytes: &[u8]) -> Option<Option<usize>> {
if bytes.first() != Some(&0x1b) {
return None;
}
let allow_bel = if bytes.starts_with(b"\x1b]") {
true
} else if bytes
.get(1)
.is_some_and(|byte| matches!(*byte, b'P' | b'_' | b'^' | b'X'))
{
false
} else {
return None;
};
for offset in 2..bytes.len() {
if allow_bel && bytes[offset] == 0x07 {
return Some(Some(offset + 1));
}
if bytes[offset..].starts_with(b"\x1b\\") {
return Some(Some(offset + 2));
}
}
Some(None)
}
#[cfg(windows)]
fn query_host_escape_disambiguation() -> (bool, Vec<u8>) {
(false, Vec::new())
}
pub(super) fn write_host_color_scheme_report_mode(
writer: &mut impl io::Write,
enabled: bool,
@@ -521,6 +717,14 @@ fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Res
}
impl TerminalGuard {
pub(super) fn host_escape_disambiguation_active(&self) -> bool {
self.host_escape_disambiguation_active
}
pub(super) fn take_buffered_host_input(&mut self) -> Vec<u8> {
std::mem::take(&mut self.buffered_host_input)
}
#[cfg(windows)]
pub(super) fn recover_windows_virtual_terminal_input(&self) -> io::Result<()> {
let active = enable_windows_virtual_terminal_input(
@@ -645,6 +849,90 @@ mod tests {
}
}
#[cfg(not(windows))]
#[test]
fn host_keyboard_probe_consumes_fragmented_responses_and_preserves_input() {
let stream = b"before\x1b[?7u-middle-\x1b[?1;2cafter";
for split in 1..stream.len() {
let mut buffered = Vec::new();
let mut responses = HostKeyboardProbeResponses::default();
buffered.extend_from_slice(&stream[..split]);
consume_host_keyboard_probe_responses(&mut buffered, &mut responses);
buffered.extend_from_slice(&stream[split..]);
consume_host_keyboard_probe_responses(&mut buffered, &mut responses);
assert_eq!(responses.flags, Some(7), "split {split}");
assert!(responses.primary_device_attributes, "split {split}");
assert_eq!(buffered, b"before-middle-after", "split {split}");
}
}
#[cfg(not(windows))]
#[test]
fn host_keyboard_probe_preserves_typed_input_before_responses() {
let mut buffered = b"aPtyped\x1b[?7u\x1b[?1;2c".to_vec();
let mut responses = HostKeyboardProbeResponses::default();
consume_host_keyboard_probe_responses(&mut buffered, &mut responses);
assert!(host_escape_disambiguation_confirmed(&responses));
assert_eq!(buffered, b"aPtyped");
}
#[cfg(not(windows))]
#[test]
fn host_keyboard_probe_requires_disambiguation_bit_and_device_attributes() {
for (flags, expected) in [(0, false), (2, false), (7, true)] {
let mut buffered = format!("\x1b[?{flags}u\x1b[?1;2c").into_bytes();
let mut responses = HostKeyboardProbeResponses::default();
consume_host_keyboard_probe_responses(&mut buffered, &mut responses);
assert_eq!(host_escape_disambiguation_confirmed(&responses), expected);
assert!(buffered.is_empty());
}
}
#[cfg(not(windows))]
#[test]
fn host_keyboard_probe_requires_flags_before_device_attributes() {
let mut buffered = b"\x1b[?1;2c\x1b[?7uinput".to_vec();
let mut responses = HostKeyboardProbeResponses::default();
consume_host_keyboard_probe_responses(&mut buffered, &mut responses);
assert_eq!(responses.flags, None);
assert!(responses.primary_device_attributes);
assert_eq!(buffered, b"input");
}
#[cfg(not(windows))]
#[test]
fn host_keyboard_probe_preserves_response_shaped_payloads() {
let opaque = b"\x1b[200~paste \x1b[?1u \x1b[?1;2c\x1b[201~-\x1bPdata \x1b[?7u\x1b\\";
let mut buffered = [opaque.as_slice(), b"\x1b[?7u\x1b[?1;2c"].concat();
let mut responses = HostKeyboardProbeResponses::default();
consume_host_keyboard_probe_responses(&mut buffered, &mut responses);
assert!(host_escape_disambiguation_confirmed(&responses));
assert_eq!(buffered, opaque);
}
#[cfg(not(windows))]
#[test]
fn host_keyboard_probe_preserves_malformed_responses() {
let mut buffered = b"a\x1b[?7;1ub\x1b[?65536uc".to_vec();
let mut responses = HostKeyboardProbeResponses::default();
consume_host_keyboard_probe_responses(&mut buffered, &mut responses);
assert_eq!(responses.flags, None);
assert!(!responses.primary_device_attributes);
assert_eq!(buffered, b"a\x1b[?7;1ub\x1b[?65536uc");
}
#[test]
fn windows_native_mouse_capture_reasserts_final_encoding_after_native_enable() {
let mut output = SharedOutput::default();
+9 -7
View File
@@ -1,7 +1,7 @@
id = "codex"
version = "2026.09.14.1"
version = "2026.09.15.1"
min_engine_version = 3
updated_at = "2026-09-14T00:00:00Z"
updated_at = "2026-09-15T00:00:00Z"
[[rules]]
id = "osc_title_blocked"
@@ -84,11 +84,13 @@ state = "working"
priority = 500
region = "before_current_prompt_marker"
visible_working = true
# Cheap negative filter before the multiline search.
contains = ["esc to interrupt"]
# Animations can hide the bullet, and queued inputs can sit below a dynamic activity label.
# Require the live timer/control suffix with no later response or interruption marker.
regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s • esc to interrupt\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+esc\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
# Support animated and reduced-motion status, including dynamic activity labels.
# The interrupt hint can be remapped, unbound, or hidden, and queued inputs can
# sit below the status. Require the live timer suffix with no later response.
any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }]
regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
# A failed reconnect keeps its final elapsed timer but is no longer working.
not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }]
[[rules]]
id = "osc_title_idle"
+72 -38
View File
@@ -1,63 +1,97 @@
id = "kiro"
version = "2026.08.01.1"
min_engine_version = 1
updated_at = "2026-08-01T00:00:00Z"
version = "2026.09.19.1"
min_engine_version = 2
updated_at = "2026-09-19T00:00:00Z"
aliases = ["kiro-cli"]
[[rules]]
id = "live_prompt_idle"
state = "idle"
priority = 1100
region = "bottom_non_empty_lines(4)"
visible_idle = true
line_regex = ['(?i)^\s*[>]\s*ask a question or describe a task(?:\s+(?:enter|↵))?\s*$']
[[rules]]
id = "tool_approval"
state = "blocked"
priority = 300
region = "whole_recent"
priority = 1050
region = "bottom_non_empty_lines(8)"
visible_blocker = true
contains = ["requires approval"]
any = [
{ contains = ["yes, single permission"] },
{ contains = ["trust, always allow"] },
{ contains = ["no (tab to edit)"] },
{ contains = ["esc to close"] },
{ all = [
{ regex = ['(?is)(?:^|\n)\s*esc\s+to\s+close\b(?:[^\n]*\bto\s+navigate\b[^\n]*|.*\bto\s+navigate\b.*\bto\s+select\b.*\btab\s+to\s+edit|[^\n]*\benter\s+to\s+see\s+more\s+options)\s*\z'] },
{ any = [
{ line_regex = [
'(?i)^\s*[>]\s*(?:allow|always allow|deny|always deny)\s*$',
'(?i)^\s*(?:[>]\s*)?allow\s*$',
'(?i)^\s*(?:[>]\s*)?always allow\s*$',
'(?i)^\s*(?:[>]\s*)?deny\s*$',
'(?i)^\s*(?:[>]\s*)?always deny\s*$',
] },
{ line_regex = [
'(?i)^\s*[>]\s*(?:yes, single permission|trust, always allow in this session|no \(tab to edit\))\s*$',
'(?i)^\s*(?:[>]\s*)?yes, single permission\s*$',
'(?i)^\s*(?:[>]\s*)?trust, always allow in this session\s*$',
'(?i)^\s*(?:[>]\s*)?no \(tab to edit\)\s*$',
] },
{ line_regex = [
'(?i)^\s*[>]\s*(?:trust\b.*|entire tool)\s*$',
'(?i)^\s*(?:[>]\s*)?(?:trust )?entire tool(?:\s+\([^\n]*\))?(?:\s+(?:session|workspace|always))?\s*$',
] },
] },
] },
]
[[rules]]
id = "subagent_approval"
id = "tool_approval_edit"
state = "blocked"
priority = 290
priority = 1045
region = "whole_recent"
visible_blocker = true
contains = ["pending from subagents"]
any = [
{ contains = ["tool approval"] },
{ contains = ["tool approvals"] },
]
all = [
{ any = [{ contains = ["approve all pending"] }, { contains = ["configure individually"] }, { contains = ["exit (cancel subagents)"] }] },
regex = ['(?im)(?:^|\n)[ \t]*[-─]+[ \t]*\n[^\n]*requires\s+approval\s*[·.]\s*modify\s+request[ \t]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*?[ \t]*[>][ \t]*[^\n]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*[ \t]*[-─]+[ \t]*\n[ \t]*esc[ \t]+to[ \t]+close[ \t]*\n?\z']
[[rules]]
id = "crew_approval"
state = "blocked"
priority = 1040
region = "bottom_non_empty_lines(8)"
visible_blocker = true
contains = [
"tool approval",
"approve all pending",
"configure individually (agent monitor)",
"exit (cancel subagents)",
]
[[rules]]
id = "prompt_idle"
state = "idle"
priority = 200
region = "bottom_non_empty_lines(5)"
visible_idle = true
contains = ["ask a question or describe a task", "/copy to clipboard"]
not = [
{ contains = ["kiro is working"] },
{ contains = ["esc to cancel"] },
]
id = "question_panel"
state = "blocked"
priority = 1030
region = "bottom_non_empty_lines(8)"
visible_blocker = true
contains = ["to navigate", "to submit", "esc to cancel"]
[[rules]]
id = "kiro_working_marker"
id = "live_working_footer"
state = "working"
priority = 100
region = "whole_recent"
priority = 950
region = "bottom_non_empty_lines(4)"
visible_working = true
contains = ["kiro is working"]
contains = ["kiro is working", "type to steer", "ctrl+s to queue"]
[[rules]]
id = "tool_spinner_working"
id = "osc_title_working"
state = "working"
priority = 90
region = "whole_recent"
priority = 900
region = "osc_title"
visible_working = true
contains = ["esc to cancel"]
line_regex = ['^\s*(◔|◑|◕|●)\s+\p{Alphabetic}']
regex = ['(?i)^[◐◓◑◒/|\\-]\s+kiro:']
[[rules]]
id = "osc_progress_working"
state = "working"
priority = 890
region = "osc_progress"
visible_working = true
regex = ['^4;3;?$']
+89 -20
View File
@@ -102,10 +102,22 @@ pub(crate) struct CursorPositionSettleState {
impl CursorPositionSettleState {
pub(crate) fn observe(&mut self, current: Option<TerminalCursorState>, now: Instant) {
// A candidate that stayed quiet for its hold window is real, so preserve
// it before considering the first (possibly temporary) position of a
// later redraw.
// A return to the caret's position or row ends the redraw hold, even
// when typing advanced its column. Otherwise the accumulated typing
// deadline can settle a later repair cell and anchor redraws there.
if let (Some(candidate), Some(since)) = (self.candidate, self.candidate_since) {
let restored = self.settled.zip(current).is_some_and(|(settled, current)| {
settled.visible
&& (same_cursor_position(settled, current)
|| (candidate.y != settled.y
&& current.y == settled.y
&& now.duration_since(since) < self.candidate_hold()))
});
if restored {
self.settle(current);
return;
}
// Preserve an eligible caret before a later redraw moves it away.
if now.duration_since(since) >= self.candidate_hold() {
self.settle(Some(candidate));
}
@@ -264,25 +276,82 @@ mod tests {
#[test]
fn cursor_settle_keeps_previous_caret_during_next_system_conpty_redraw() {
let now = Instant::now();
let mut settle = CursorPositionSettleState::default();
let caret = cursor(2, 12, true, 0);
let typed_caret = cursor(3, 12, true, 0);
let repair = cursor(0, 10, true, 0);
settle.observe(Some(caret), now);
settle.observe(Some(typed_caret), now + Duration::from_millis(1));
for (next_caret, repair) in [
(cursor(3, 12, true, 0), cursor(0, 10, true, 0)),
(cursor(20, 12, true, 0), cursor(0, 10, true, 0)),
(cursor(2, 13, true, 0), cursor(0, 10, true, 0)),
(cursor(2, 13, true, 0), cursor(0, 12, true, 0)),
] {
let mut settle = CursorPositionSettleState::default();
settle.observe(Some(caret), now);
settle.observe(Some(next_caret), now + Duration::from_millis(1));
// System ConPTY closes the next frame at the repair cell, then emits
// the caret restoration separately about 10 ms later.
settle.observe(Some(repair), now + Duration::from_millis(160));
assert_eq!(
settle.reported_cursor(Some(repair), now + Duration::from_millis(161)),
Some(typed_caret)
);
settle.observe(Some(typed_caret), now + Duration::from_millis(170));
assert_eq!(
settle.reported_cursor(Some(typed_caret), now + Duration::from_millis(171)),
Some(typed_caret)
);
// Preserve real typing, Home/End, and row moves before the next
// ConPTY redraw parks briefly at its repair cell.
settle.observe(Some(repair), now + Duration::from_millis(160));
assert_eq!(
settle.reported_cursor(Some(repair), now + Duration::from_millis(161)),
Some(next_caret)
);
settle.observe(Some(next_caret), now + Duration::from_millis(170));
assert_eq!(
settle.reported_cursor(Some(next_caret), now + Duration::from_millis(171)),
Some(next_caret)
);
}
}
#[test]
fn cursor_settle_recovers_from_a_late_restore_during_continuous_redraws() {
let now = Instant::now();
let caret = cursor(6, 9, true, 0);
let park = cursor(0, 7, true, 0);
for (restored, first_restore) in [
(caret, 161),
(cursor(7, 9, true, 0), 71),
(cursor(30, 9, true, 0), 71),
] {
let mut settle = CursorPositionSettleState::default();
settle.observe(Some(caret), now);
settle.observe(Some(park), now + Duration::from_millis(1));
// Exact restoration can miss the max hold. A changed column only
// restores the old row while the new row is still provisional.
for ms in (first_restore..first_restore + 1800).step_by(30) {
let restored_at = now + Duration::from_millis(ms);
settle.observe(Some(restored), restored_at);
assert_eq!(
settle.reported_cursor(Some(restored), restored_at),
Some(restored),
"the repair cell must not become the anchor for later redraws"
);
settle.observe(Some(park), restored_at + Duration::from_millis(20));
}
}
}
#[test]
fn cursor_settle_keeps_typing_deadlines_from_adopting_redraw_positions() {
let now = Instant::now();
let mut settle = CursorPositionSettleState::default();
let park = cursor(0, 37, true, 0);
settle.observe(Some(cursor(3, 39, true, 0)), now);
settle.observe(Some(cursor(4, 39, true, 0)), now + Duration::from_millis(1));
// Fast typing advances the caret while redraws briefly visit another
// row. A burst's max deadline can fall on one of those repair writes.
for step in 0..60 {
let parked_at = now + Duration::from_millis(11 + step * 30);
settle.observe(Some(park), parked_at);
assert_eq!(
settle.reported_cursor(Some(park), parked_at).unwrap().y,
39,
"a fresh repair position must not inherit the typing deadline"
);
let caret = cursor(6 + step as u16 * 2, 39, true, 0);
settle.observe(Some(caret), parked_at + Duration::from_millis(10));
}
}
#[test]
+1 -1
View File
@@ -78,7 +78,7 @@ impl ChildExitReason {
}
#[cfg(unix)]
pub(crate) use unix_common::classify_child_exit;
pub(crate) use unix_common::{classify_child_exit, poll_fd_readable, read_fd};
#[cfg(not(any(unix, windows)))]
pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason {
+23
View File
@@ -8,6 +8,29 @@ pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::C
}
}
pub(crate) fn read_fd(fd: std::os::fd::RawFd, data: &mut [u8]) -> std::io::Result<usize> {
let result = unsafe { libc::read(fd, data.as_mut_ptr().cast(), data.len()) };
if result < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(result as usize)
}
}
pub(crate) fn poll_fd_readable(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result<bool> {
let mut descriptor = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let result = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) };
if result < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(result > 0)
}
}
pub(crate) fn shutdown_client_stream(stream: &crate::ipc::LocalStream) -> std::io::Result<()> {
let crate::ipc::LocalStream::UdSocket(stream) = stream;
stream.inner().shutdown(std::net::Shutdown::Both)
+109
View File
@@ -150,6 +150,7 @@ pub(crate) struct RawInputByteFramer {
host_color_scheme_change_tracking: bool,
host_appearance_query_on_focus: bool,
split_coalesced_escape: bool,
host_escape_disambiguation_active: bool,
}
const HOST_COLOR_QUERY_REPLIES: u16 = 258;
@@ -218,6 +219,11 @@ impl RawInputByteFramer {
!self.buffer.is_empty()
}
#[cfg(any(unix, test))]
pub(crate) fn set_host_escape_disambiguation_active(&mut self, active: bool) {
self.host_escape_disambiguation_active = active;
}
#[cfg(unix)]
pub(crate) fn has_pending_lone_escape(&self) -> bool {
self.buffer.as_slice() == [ESC]
@@ -267,6 +273,16 @@ impl RawInputByteFramer {
return chunks;
}
if self.host_escape_disambiguation_active
&& starts_with_bounded_incomplete_escape_sequence(&self.buffer)
{
tracing::trace!(
len = self.buffer.len(),
"holding incomplete host escape sequence with disambiguation active"
);
return chunks;
}
if self.lone_escape_recently_flushed && self.buffer.starts_with(b"[<") {
tracing::debug!(
len = self.buffer.len(),
@@ -472,6 +488,15 @@ impl RawInputByteFramer {
continue;
}
if self.host_escape_disambiguation_active
&& self.buffer.first() == Some(&ESC)
&& self.buffer.len() > 1
&& !starts_with_known_escape_introducer(&self.buffer)
{
self.buffer.drain(..1);
continue;
}
let Some((event, consumed)) = extract_one_event(&self.buffer) else {
break;
};
@@ -831,6 +856,25 @@ fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool {
.all(|byte| byte.is_ascii_digit() || *byte == b';')
}
fn starts_with_known_escape_introducer(buffer: &[u8]) -> bool {
buffer
.get(1)
.is_some_and(|byte| matches!(*byte, b'[' | b'O' | b']' | b'P' | b'_' | b'^' | b'X' | ESC))
}
fn starts_with_bounded_incomplete_escape_sequence(buffer: &[u8]) -> bool {
if buffer.len() >= MAX_DISCARDED_CONTROL_TAIL_BYTES {
return false;
}
if buffer == [ESC] || buffer == b"\x1bO" {
return true;
}
let Some(body) = buffer.strip_prefix(b"\x1b[") else {
return false;
};
body.iter().all(|byte| matches!(*byte, 0x20..=0x3f))
}
#[cfg(any(unix, windows, test))]
fn starts_with_incomplete_default_mouse_sequence(buffer: &[u8]) -> bool {
buffer.starts_with(b"\x1b[M") && buffer.len() < 6
@@ -2016,6 +2060,71 @@ mod tests {
assert!(framer.timed_out_mouse_prefix.is_none());
}
#[test]
fn confirmed_host_disambiguation_retains_split_sgr_mouse_without_escape() {
for (prefix, tail) in [
(b"\x1b".as_slice(), b"[<0;5;5M".as_slice()),
(b"\x1b[".as_slice(), b"<0;5;5M".as_slice()),
(b"\x1b[<0;".as_slice(), b"5;5M".as_slice()),
] {
let mut framer = RawInputFramer::default();
framer
.byte_framer
.set_host_escape_disambiguation_active(true);
assert!(framer.push(prefix).is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.flush_timeout().is_empty());
let events = framer.push(tail);
assert!(matches!(
events.as_slice(),
[RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 4,
row: 4,
..
})]
));
}
}
#[test]
fn confirmed_host_disambiguation_drops_stale_escape_before_plain_input() {
let mut framer = RawInputFramer::default();
framer
.byte_framer
.set_host_escape_disambiguation_active(true);
assert!(framer.push(b"\x1b").is_empty());
assert!(framer.flush_timeout().is_empty());
let events = framer.push(b"x");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('x'),
KeyModifiers::empty(),
);
}
#[test]
fn confirmed_host_disambiguation_keeps_kitty_escape_immediate() {
let mut framer = RawInputFramer::default();
framer
.byte_framer
.set_host_escape_disambiguation_active(true);
let events = framer.push(b"\x1b[27u");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Esc,
KeyModifiers::empty(),
);
}
#[test]
fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() {
let mut framer = RawInputFramer::default();
+8
View File
@@ -293,6 +293,14 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu
/// 2. If no server → spawn server daemon → wait for socket readiness
/// 3. Run the thin client (which connects to the server)
pub fn auto_detect_launch(saved_federation: bool) -> io::Result<()> {
// The client requires terminal geometry before it can attach. Reject an
// unusable terminal before socket lookup creates directories or starts a daemon.
crate::platform::terminal_grid_size().map_err(|err| {
io::Error::new(
err.kind(),
format!("cannot attach without a usable terminal: {err}; run inside a terminal"),
)
})?;
let socket_path = client_socket_path();
info!(path = %socket_path.display(), "auto-detect launch starting");
+67
View File
@@ -237,6 +237,73 @@ fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
// Tests
// ---------------------------------------------------------------------------
/// A rejected noninteractive attach must not create a session or start its daemon.
#[test]
fn session_attach_without_terminal_leaves_no_session() {
use std::os::unix::process::CommandExt as _;
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
register_runtime_dir(&runtime_dir);
let name = "no-tty";
let app_dir = if cfg!(debug_assertions) {
"herdr-dev"
} else {
"herdr"
};
let session_dir = config_home.join(app_dir).join("sessions").join(name);
let run = |args: &[&str]| {
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command
.args(args)
.env("XDG_CONFIG_HOME", &config_home)
.env("XDG_RUNTIME_DIR", &runtime_dir)
.env_remove("HERDR_CONFIG_PATH")
.env_remove("HERDR_ENV")
.env_remove("HERDR_SESSION")
.env_remove("HERDR_SOCKET_PATH")
.env_remove("HERDR_CLIENT_SOCKET_PATH")
.env_remove("HERDR_WORKSPACE_ID")
.env_remove("HERDR_TAB_ID")
.env_remove("HERDR_PANE_ID");
// Piped stdio alone can still leave /dev/tty usable. Match noninteractive
// SSH by detaching the child from the test runner's controlling terminal.
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
command.output().unwrap()
};
let before = run(&["session", "list", "--json"]);
let output = run(&["session", "attach", name]);
let session_created = session_dir.exists();
let after = run(&["session", "list", "--json"]);
// Clean up even when a regression started a daemon, before assertions panic.
cleanup_test_base(&base);
assert!(
!session_created,
"failed attach created a session directory"
);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("terminal"), "stderr={stderr}");
assert!(stderr.contains("run inside a terminal"), "stderr={stderr}");
assert!(
output.stdout.is_empty(),
"failed attach emitted terminal output"
);
assert!(before.status.success());
assert!(after.status.success());
assert_eq!(before.stdout, after.stdout, "session inventory changed");
}
/// Running `herdr` with no server present starts a server
/// and attaches as client.
#[test]
+5
View File
@@ -344,6 +344,11 @@ fn direct_attach_initial_mouse_capture_follows_config() {
"direct attach must enable host bracketed paste; output: {:?}",
read_output(&output)
);
assert!(
!read_output(&output).contains("\x1b[?u"),
"direct attach must not query rendered-client keyboard state; output: {:?}",
read_output(&output)
);
let restore_watermark = output_len(&output);
attach