diff --git a/.yoi/tickets/00001KV4YAAVY/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV4YAAVY/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..bb066727 --- /dev/null +++ b/.yoi/tickets/00001KV4YAAVY/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260615-063825-1","ticket_id":"00001KV4YAAVY","kind":"accepted_plan","accepted_plan":{"summary":"Accept single-Pod View text selection/copy work. Implement mouse drag selection, highlight, Esc clear, y copy+clear, deterministic multi-item extraction, and focused tests without changing Panel row selection semantics.","branch":"impl/00001KV4YAAVY-single-pod-text-selection","worktree":"/home/hare/Projects/yoi/.worktree/00001KV4YAAVY-single-pod-text-selection","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. This single-Pod View work is source/logically disjoint from Panel composer work enough for parallel start."},"author":"yoi-orchestrator","at":"2026-06-15T06:38:25Z"} diff --git a/.yoi/tickets/00001KV4YAAVY/item.md b/.yoi/tickets/00001KV4YAAVY/item.md index fec030e2..693fb7ca 100644 --- a/.yoi/tickets/00001KV4YAAVY/item.md +++ b/.yoi/tickets/00001KV4YAAVY/item.md @@ -1,8 +1,8 @@ --- title: 'single-Pod View Item text をマウスドラッグで選択・コピーできるようにする' -state: 'queued' +state: 'done' created_at: '2026-06-15T06:08:19Z' -updated_at: '2026-06-15T06:37:00Z' +updated_at: '2026-06-15T07:15:54Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui', 'mouse-input', 'selection', 'clipboard', 'single-pod-view'] diff --git a/.yoi/tickets/00001KV4YAAVY/thread.md b/.yoi/tickets/00001KV4YAAVY/thread.md index f1ebd0c7..00756eb6 100644 --- a/.yoi/tickets/00001KV4YAAVY/thread.md +++ b/.yoi/tickets/00001KV4YAAVY/thread.md @@ -13,4 +13,254 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Panel Queue により routing が明示的に許可され、Ticket は `queued`。 +- Ticket body / thread / relation / OrchestrationPlan / Orchestrator workspace state を確認した。blocking relation はなく、planning に戻す concrete missing information はない。 +- 対象は single-Pod conversation View の text-like Item selection/copy に限定され、Panel row selection とは scope が分かれている。 +- Tool/non-text Item と copy target は implementation-time decision として許容されており、binding invariants / acceptance criteria / escalation conditions が明確。 + +Evidence checked: +- Ticket body/thread: requirements、copy target、tool/non-text handling、acceptance criteria、non-goals、related work を確認。 +- Ticket relations: `00001KV072V89` と `00001KV10SN02` は related のみで blocker ではない。 +- OrchestrationPlan: 既存 record なし。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`f0de8413` 上。 +- Visible Pods: implementation child Pod なし。Intake peer は routing blocker ではない。 +- Bounded code map: `crates/tui/src/single_pod.rs`、copy/clipboard abstraction、mouse/selection/key handling tests。 + +IntentPacket: + +Intent: +- single-Pod TUI conversation View で User/System/Assistant など text-like Item の表示テキストを mouse drag で選択し、`y` で copy、`Esc` で clear できるようにする。 + +Binding decisions / invariants: +- 対象は single-Pod View Item text。Panel Ticket/Pod row selection は変更しない。 +- terminal-native text selection preservation は non-goal。View drag は Yoi text selection として扱う。 +- selected/copy text、selection state、clipboard diagnostics は Pod history / model context / session log / Ticket records に残さない。 +- composer input、scroll、rewind picker、modal/popup、normal key handling と衝突させない。 +- bare Panel row mouse selection semantics は regress させない。 +- Tool/non-text Item handling と copy target は実装報告または decision comment に明示する。 + +Requirements / acceptance criteria: +- UserItem / SystemItem / AssistantItem text を drag selection できる。 +- drag start/update/release 後に selection state が残り、View 上で highlight される。 +- `Esc` で selection clear。 +- `y` で selected text を copy し、selection clear。 +- text-like Item を跨いだ selection が deterministic separator で copy される。 +- non-text/tool item handling decision が明示され、test で固定される。 +- copy 成功/失敗が user-visible で、secret-like diagnostics を出さない。 +- tests cover coordinate mapping、selection state、multi-item extraction、Esc、copy+clear、non-text/tool handling。 + +Implementation latitude: +- Existing clipboard abstraction があれば利用。なければ testable な最小 copy path を追加してよい。 +- OSC52/system clipboard/internal copy buffer の選択は既存 TUI architecture に合わせる。 +- rendering/highlight 表現、selection model の internal shape、separator は実装側判断。ただし deterministic にする。 + +Escalate if: +- terminal/crossterm event stream で必要な drag/release を識別できない。 +- existing rendering model から text coordinate mapping を安全に取れない。 +- copy target を追加すると secrets/history/model-context boundary に影響する。 +- rewind/modal/composer key handling と共存できない大きな model change が必要。 + +Validation: +- focused `cargo test -p tui ...` for single_pod selection/copy。 +- `cargo check -p tui --all-targets`。 +- `cargo fmt --check`。 +- `git diff --check`。 +- Panel row mouse selection regression test if related mouse plumbing is touched。 + +Critical risks / reviewer focus: +- selection/copy state の persistence leakage。 +- mouse coordinate -> rendered text mapping correctness。 +- multi-item extraction ordering/separator。 +- composer/scroll/rewind/modal conflict。 +- clipboard path safety and testability。 +- Panel mouse behavior regression absence。 + +--- + + + +## State changed + +Routing decision と accepted implementation plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、single-Pod View work は同時に開始する Panel composer work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。 + +--- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KV4YAAVY`: + +Commit: +- `09f5e9d5 feat: add single-pod text selection` + +Changed files: +- `crates/tui/src/text_selection.rs` + - New transient selection state, coordinate mapping, extraction, and focused tests. +- `crates/tui/src/app.rs` + - Local non-persistent selection state on `App`. +- `crates/tui/src/ui.rs` + - Selectable history row metadata, highlight rendering, snapshot wiring, selectable/non-text tests. +- `crates/tui/src/single_pod.rs` + - Drag mouse handling, `Esc` clear, `y` copy+clear, OSC52 copy, tests. +- `crates/tui/src/lib.rs` + - Module registration. +- `crates/tui/Cargo.toml`, `Cargo.lock` + - Added direct `base64` dependency for OSC52 clipboard payloads. + +Behavior / implementation decisions: +- Selectable Items: + - `UserMessage` + - `SystemMessage` + - `AssistantText` +- Non-text/tool Items: + - tool calls, thinking, stats, greetings, alerts, turn headers, etc. are not selectable/copyable through this path. + - extraction skips non-selectable rows. +- Cross-item selection separator: + - copied text follows rendered row order and preserves selectable blank spacer rows, giving deterministic `\n\n` between adjacent text-like Items. +- Copy target: + - terminal clipboard via OSC52 (`ESC ] 52 ; c ; base64 BEL`), not OS clipboard APIs. + - avoids adding platform clipboard runtime behavior. +- Copy diagnostics: + - actionbar-only sanitized messages: + - success: `copied selected text to terminal clipboard` + - failure: `terminal clipboard write failed` + - no selected content appears in diagnostics. +- Selection persistence: + - selection state/copied text remains TUI-local only. + - not appended to Pod history, model context, session logs, Ticket records, memory, or durable state. +- Mouse tracking: + - single-Pod TUI now enables button-event drag tracking (`?1002h`) but not all-motion tracking (`?1003h`). + - Panel code was not changed. + +Validation reported by coder: +- Passed: `cargo fmt --check` +- Passed: `cargo check -p tui --all-targets` +- Passed: `cargo test -p tui text_selection` +- Passed: `cargo test -p tui single_pod::tests` +- Passed: `cargo test -p tui ui::tests::history_rows_mark_text_items_selectable_and_non_text_unselectable` +- Passed: `git diff --check` + +Repository status: +- Child implementation worktree clean after commit. + +Residual risks / notes: +- OSC52 clipboard success means sequence written/flushed; actual clipboard acceptance depends on terminal/multiplexer policy. +- No real PTY/E2E validation was run; coverage is focused unit-level mouse/key/selection/render extraction behavior. + +--- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Reviewed child worktree only: + - `/home/hare/Projects/yoi/.worktree/00001KV4YAAVY-single-pod-text-selection` + - diff `368249d6..09f5e9d5` + - Ticket record/thread in the child worktree +- Read-only validation: + - Passed: `git diff --check 368249d6..09f5e9d5` + +Implementation review: +- TUI-local selection state was added in `crates/tui/src/text_selection.rs` and wired into single-Pod rendering/input. +- Mouse drag maps terminal coordinates through `HistoryViewport` into rendered history rows. +- Drag start/update/release are handled in `single_pod.rs`; selection remains active after release. +- `Esc` clears selection before normal composer handling. +- bare `y` copies only when a selection exists; otherwise composer input remains available. +- Highlighting is applied through `range_for_row` and `highlight_line_selection`. +- Selectable item boundary is explicit: + - selectable: `UserMessage`, `SystemMessage`, `AssistantText` + - non-selectable: tool calls, thinking, notices, stats, greetings, etc. +- Tool/non-text rows are marked unselectable and extraction skips unselectable rows. +- Cross-item extraction is deterministic, with documented newline/blank-line behavior. +- Copy uses OSC52; selected text is base64 encoded before writing the OSC52 sequence. +- Copy diagnostics are generic/actionbar-local and do not include selected content. +- Selection state lives in TUI-local `App.text_selection`; no path was found that appends selected/copied text to Pod history, model context, session log, Ticket records, memory, or durable state. +- Mouse capture changes are limited to single-Pod fullscreen setup and avoid all-motion `?1003h`; Panel/multi-pod mouse row selection code was not modified. +- Composer, scroll, rewind picker, completion/key routing, and normal key handling interactions were inspected and no blocker found. + +Coverage reviewed: +- coordinate mapping +- drag update/release state +- multi-row / separator extraction +- non-selectable row skipping +- Esc clear +- copy helper + clear +- UI selectable/non-selectable marking +- mouse capture mode avoiding all-motion tracking + +Non-blocking notes: +- No PTY/E2E validation was run; terminal-specific OSC52/mouse behavior still depends on real terminal behavior. +- OSC52 cannot acknowledge terminal clipboard acceptance; implementation can only report write/flush success/failure. +- There is no explicit byte-size cap on OSC52 payload, but selection is bounded by visible rendered viewport rather than arbitrary full history traversal. Future hard cap/error notice would be reasonable hardening. +- A full fixture test constructing an actual `Block::ToolCall` could strengthen coverage, but the non-text path is explicit and source-inspected. + +Validation not rerun by reviewer: +- Cargo validation was not rerun because review scope was read-only and Cargo writes build artifacts. Coder-reported validation was inspected as evidence. + +Conclusion: +- Approved. No blocker remains. + +--- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KV4YAAVY-single-pod-text-selection` +- implementation commit: `09f5e9d5 feat: add single-pod text selection` +- merge commit: `3fa52f2c merge: single-pod text selection` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KV4YAAVY`. + +Additional integration fix: +- Because the implementation added a direct `base64` dependency to `crates/tui/Cargo.toml` and updated `Cargo.lock`, `nix build .#yoi --no-link` first failed with stale `package.nix` `cargoHash`. +- Updated `package.nix` `cargoHash` to the new fixed-output vendor hash: + - `sha256-pIDYnbBs3U8Z3IndgH10rirv8/IdFv1WlgwpCbKXy+M=` + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo fmt --check` +- Passed: `cargo check -p tui --all-targets` +- Passed: `cargo test -p tui text_selection` + - 4 passed +- Passed: `cargo test -p tui single_pod::tests` + - 38 passed +- Passed: `cargo test -p tui ui::tests::history_rows_mark_text_items_selectable_and_non_text_unselectable` + - 1 passed +- Passed: `git diff --check` +- Passed after hash update: `nix build .#yoi --no-link` + +Notes: +- OSC52 clipboard acceptance remains terminal/multiplexer dependent; implementation reports write/flush success/failure only. +- No PTY/E2E validation was run for real terminal selection/copy behavior; focused unit coverage and reviewer inspection covered selection/copy state, extraction, and persistence boundaries. +- Orchestrator worktree is clean apart from the pending `package.nix`/Ticket integration commit at the time of recording. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved, implementation branch merged into the orchestration branch, integration validation passed including Nix package hash refresh, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + --- diff --git a/.yoi/tickets/00001KV4ZDMV1/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV4ZDMV1/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..cfb112d6 --- /dev/null +++ b/.yoi/tickets/00001KV4ZDMV1/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260615-063902-1","ticket_id":"00001KV4ZDMV1","kind":"accepted_plan","accepted_plan":{"summary":"Accept Panel Alt+Enter newline bugfix. Preserve bare Enter semantics and shared composer key handling while adding focused regression coverage.","branch":"impl/00001KV4ZDMV1-panel-alt-enter-newline","worktree":"/home/hare/Projects/yoi/.worktree/00001KV4ZDMV1-panel-alt-enter-newline","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Run in parallel with single-Pod selection work; defer Panel row hierarchy visual work to avoid overlapping Panel rendering edits."},"author":"yoi-orchestrator","at":"2026-06-15T06:39:02Z"} diff --git a/.yoi/tickets/00001KV4ZDMV1/item.md b/.yoi/tickets/00001KV4ZDMV1/item.md index d260190c..a271a006 100644 --- a/.yoi/tickets/00001KV4ZDMV1/item.md +++ b/.yoi/tickets/00001KV4ZDMV1/item.md @@ -1,8 +1,8 @@ --- title: 'Panel composer で Alt+Enter 改行を SessionView と揃える' -state: 'queued' +state: 'done' created_at: '2026-06-15T06:27:36Z' -updated_at: '2026-06-15T06:37:01Z' +updated_at: '2026-06-15T06:51:06Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-input', 'ux-consistency'] diff --git a/.yoi/tickets/00001KV4ZDMV1/thread.md b/.yoi/tickets/00001KV4ZDMV1/thread.md index 389cea42..3244fb3a 100644 --- a/.yoi/tickets/00001KV4ZDMV1/thread.md +++ b/.yoi/tickets/00001KV4ZDMV1/thread.md @@ -13,4 +13,198 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Panel Queue により routing が明示的に許可され、Ticket は `queued`。 +- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。blocking relation はなく、planning に戻す concrete missing information はない。 +- 既存 shared composer key handling 方針があり、主な不確実性は Panel の Enter 分岐順序/修飾キー処理の local fix と regression coverage に閉じている。 +- 同時 queued の `00001KV4YAAVY` は single-Pod View selection で主対象が異なるため並行開始可能。`00001KV4ZPAD3` は Panel row rendering surface と重なるため待機に回す。 + +Evidence checked: +- Ticket body/thread: Background、requirements、acceptance criteria、binding decisions、validation、related work を確認。 +- Ticket relations: blocker なし。 +- OrchestrationPlan: 既存 record なし。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`f0de8413` 上。 +- Visible Pods: implementation child Pod なし。 +- Bounded code map: `crates/tui/src/composer_keys.rs`, `crates/tui/src/multi_pod.rs`, relevant Panel composer tests。 + +IntentPacket: + +Intent: +- Workspace Panel composer で `Alt+Enter` を SessionView / shared composer key handling と同じ改行挿入として扱い、submit/open/dispatch を起こさないようにする。 + +Binding decisions / invariants: +- `Alt+Enter` は composer editing key。Panel action key ではない。 +- bare `Enter` と `Alt+Enter` の意味を明確に分離する。 +- Panel composer は shared `composer_keys` 方針に従う。 +- Panel row selection / Ticket action semantics、Companion / Ticket Intake target semantics は変更しない。 +- bare letter shortcuts は復活させない。 + +Requirements / acceptance criteria: +- Panel composer に text がある状態で `Alt+Enter` が draft newline を挿入する。 +- composer empty / row selected / Ticket action selected でも `Alt+Enter` は submit/open/dispatch しない。 +- bare `Enter` の existing submit/open/dispatch behavior は維持される。 +- SessionView 側の `Alt+Enter` newline behavior は壊さない。 +- focused test で Panel `Alt+Enter` newline and no action を確認する。 + +Implementation latitude: +- `composer_edit_action(key)` を Enter action 分岐より前に適用するか、Enter 分岐で modifiers を除外するかは実装判断。 +- Existing tests への追加または新規 focused regression test のどちらでもよい。 +- UI hint update は必要なら最小限。 + +Escalate if: +- terminal/crossterm が対象環境で `Alt+Enter` を識別できない。 +- edit-first 処理が completion / target switching / row action 前提と衝突する。 +- SessionView 側にも同根の regression が見つかる。 + +Validation: +- `cargo test -p tui composer_keys` +- Panel focused tests / `cargo test -p tui multi_pod --lib` など変更箇所に応じた subset。 +- `cargo fmt --check` +- `git diff --check` + +Critical risks / reviewer focus: +- `Alt+Enter` が action dispatch に落ちないこと。 +- bare `Enter` semantics の regression。 +- shared composer key handling 方針から外れた ad-hoc 実装になっていないこと。 +- Panel target / row selection action identity が変わらないこと。 + +--- + + + +## State changed + +Routing decision と accepted implementation plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Panel Alt+Enter bugfix は同時に開始する single-Pod View selection work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。Panel visual hierarchy work `00001KV4ZPAD3` は conflict/capacity 理由で queued のまま待機させる。 + +--- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KV4ZDMV1`: + +Commit: +- `5c339177 fix: align panel alt-enter composer handling` + +Changed files: +- `crates/tui/src/multi_pod.rs` + - Panel now resolves shared `composer_edit_action` modifier actions before Panel `Enter` actions. + - `Alt+Enter` is handled as `InsertNewline` and returns `MultiPodAction::None`, so it cannot open Pods, dispatch Ticket actions, launch Intake, or send Companion messages. + - Added focused Panel tests for: + - non-empty composer inserts newline without Companion send + - blank Pod row selection inserts newline without open + - blank Ticket action selection inserts newline without dispatch +- `crates/tui/src/composer_keys.rs` + - Added explicit shared-keymap coverage for `Alt+Enter -> InsertNewline`. + +Validation reported by coder: +- Passed: `cargo test -p tui composer_keys` +- Passed: `cargo test -p tui multi_alt_enter --lib` +- Passed focused bare-Enter regression checks: + - `cargo test -p tui multi_empty_enter_uses_open_action --lib` + - `cargo test -p tui multi_non_empty_enter_reports_companion_unavailable --lib` + - `cargo test -p tui multi_ticket_intake_enter_builds_launch_request_not_direct_send --lib` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Broader suite note: +- Attempted: `cargo test -p tui multi_pod --lib` +- Failed in existing unrelated test: + - `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` + - assertion mismatch: expected target/orchestration path vs `/repo/yoi` +- Coder assessed this as outside the Alt+Enter change path; focused Ticket tests pass. + +Repository status: +- Child implementation worktree clean after commit. + +--- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Implementation commit `5c339177` against base `368249d6` in child worktree. +- Diff limited to: + - `crates/tui/src/composer_keys.rs` + - `crates/tui/src/multi_pod.rs` +- Shared composer policy maps `Alt+Enter` to `ComposerEditAction::InsertNewline` through `composer_edit_action`. +- Panel `handle_key` consults `composer_edit_action(key)` before bare `Enter` routing and immediately applies modifier composer actions. +- This prevents `Alt+Enter` from falling through to open / dispatch / Intake launch / Companion send. +- Existing bare `Enter` routing remains structurally unchanged. +- Bare `Enter` remains excluded from shared composer editing. +- Focused Panel tests cover text composer, blank Pod row selection, and blank Ticket action selection. +- Existing bare-Enter regression tests remain present. + +Validation performed by reviewer: +- Passed: `git diff --check 368249d6..5c339177` +- Passed: `cargo fmt --check` +- Worktree remained clean. + +Validation not run by reviewer: +- Cargo tests were not rerun because review scope was read-only and tests write build artifacts. Coder-reported focused test results were reviewed as evidence. + +Broader-suite failure assessment: +- The reported broader `cargo test -p tui multi_pod --lib` failure in an orchestration-root test appears unrelated based on diff scope. The implementation does not touch orchestration worktree/root logic. + +Conclusion: +- Approved. No blocker remains. + +--- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KV4ZDMV1-panel-alt-enter-newline` +- implementation commit: `5c339177 fix: align panel alt-enter composer handling` +- merge commit: `0335cad9 merge: panel alt-enter newline` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KV4ZDMV1`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo test -p tui composer_keys` + - 4 passed +- Passed: `cargo test -p tui multi_alt_enter --lib` + - 3 passed +- Passed: `cargo test -p tui multi_empty_enter_uses_open_action --lib` + - 1 passed +- Passed: `cargo test -p tui multi_non_empty_enter_reports_companion_unavailable --lib` + - 1 passed +- Passed: `cargo test -p tui multi_ticket_intake_enter_builds_launch_request_not_direct_send --lib` + - 1 passed +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Notes: +- Broader `cargo test -p tui multi_pod --lib` was not used as a blocker because the known failure is an unrelated orchestration-root test outside the Alt+Enter change path. +- Orchestrator worktree is clean after validation. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + --- diff --git a/.yoi/tickets/00001KV4ZPAD3/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV4ZPAD3/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..b6b82583 --- /dev/null +++ b/.yoi/tickets/00001KV4ZPAD3/artifacts/orchestration-plan.jsonl @@ -0,0 +1,3 @@ +{"id":"orch-plan-20260615-063902-1","ticket_id":"00001KV4ZPAD3","kind":"conflicts_with","related_ticket":"00001KV4ZDMV1","note":"Panel rendering/input surface (`crates/tui/src/multi_pod.rs` / Panel row selection/action display) が `00001KV4ZDMV1` と重なる可能性があるため、Alt+Enter Panel composer work の outcome 後に開始する。","author":"yoi-orchestrator","at":"2026-06-15T06:39:02Z"} +{"id":"orch-plan-20260615-063916-2","ticket_id":"00001KV4ZPAD3","kind":"waiting_capacity_note","note":"`00001KV4YAAVY` と `00001KV4ZDMV1` を並行開始する。`00001KV4ZPAD3` は implementation_ready だが、Panel rendering/input surface の conflict risk と review/integration capacity のため queued のまま待機する。","author":"yoi-orchestrator","at":"2026-06-15T06:39:16Z"} +{"id":"orch-plan-20260615-065209-3","ticket_id":"00001KV4ZPAD3","kind":"accepted_plan","accepted_plan":{"summary":"Accept Panel Ticket/Intake visual hierarchy improvement after Panel Alt+Enter integration. Improve primary/secondary row styling, child-row affordance, selection clarity, and bounded tests without changing lifecycle/registry/action semantics.","branch":"impl/00001KV4ZPAD3-panel-row-hierarchy","worktree":"/home/hare/Projects/yoi/.worktree/00001KV4ZPAD3-panel-row-hierarchy","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Prior conflict with `00001KV4ZDMV1` is resolved because Alt+Enter work is merged/validated/done."},"author":"yoi-orchestrator","at":"2026-06-15T06:52:09Z"} diff --git a/.yoi/tickets/00001KV4ZPAD3/item.md b/.yoi/tickets/00001KV4ZPAD3/item.md index b93a601c..982f2bce 100644 --- a/.yoi/tickets/00001KV4ZPAD3/item.md +++ b/.yoi/tickets/00001KV4ZPAD3/item.md @@ -1,8 +1,8 @@ --- title: 'Panel Ticket / Intake Pod row の視覚階層を改善する' -state: 'queued' +state: 'done' created_at: '2026-06-15T06:32:21Z' -updated_at: '2026-06-15T06:37:02Z' +updated_at: '2026-06-15T07:09:01Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel-ux', 'tui-layout', 'accessibility', 'row-selection'] diff --git a/.yoi/tickets/00001KV4ZPAD3/thread.md b/.yoi/tickets/00001KV4ZPAD3/thread.md index a43c4eb3..8b2b1d78 100644 --- a/.yoi/tickets/00001KV4ZPAD3/thread.md +++ b/.yoi/tickets/00001KV4ZPAD3/thread.md @@ -29,4 +29,254 @@ Intake で要件・受け入れ条件・binding decisions / invariants・impleme Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready_but_waiting_conflict_capacity + +Reason: +- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。要件・受け入れ条件・invariants は実装可能な粒度で揃っており、planning に戻す concrete missing information はない。 +- ただし同時に `00001KV4ZDMV1` を Panel composer key handling work として accept する。両者は `crates/tui/src/multi_pod.rs` / Panel rendering・input surface に触れる可能性があり、並行すると row rendering / selected row / action identity regression の確認が曖昧になる。 +- `00001KV4YAAVY` は single-Pod View selection で並行開始するため、現時点では Panel visual hierarchy work を追加 spawn せず、先行 Panel composer work の outcome を待つ。 + +Evidence checked: +- Ticket body/thread: visual hierarchy/readability requirements、accessibility、row-selection invariants、validation を確認。 +- Ticket relations: blocker なし。 +- OrchestrationPlan: 既存 record なし。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`f0de8413` 上。 +- Visible Pods: implementation child Pod なし。これから `00001KV4YAAVY` と `00001KV4ZDMV1` を acceptance 予定。 + +Next action: +- `00001KV4ZDMV1` の implementation/review/integration 後、Panel rendering surface を再確認し、unblocked なら `queued -> inprogress` acceptance へ進む。 +- planning return ではなく queued のまま waiting とする。 + +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Prior waiting reason は `00001KV4ZDMV1` と Panel rendering/input surface が重なる conflict/capacity だった。 +- `00001KV4ZDMV1` は reviewer approve、orchestration branch merge、focused validation、Ticket `done` まで完了したため、prior conflict は解消した。 +- Ticket body / thread / relations / orchestration plan / current Orchestrator workspace を再確認した。blocking relation はなく、planning に戻す concrete missing information はない。 +- 現在 active な `00001KV4YAAVY` は single-Pod View Item text selection/copy であり、Panel Ticket/Intake visual hierarchy と主対象が異なるため並行開始可能。 + +Evidence checked: +- Ticket body/thread: Ticket 2行 row、associated Intake Pod row、selection/group visibility、accessibility、bounded rendering、validation を確認。 +- Ticket relations: blocker なし。 +- OrchestrationPlan: prior `conflicts_with 00001KV4ZDMV1` と waiting_capacity_note を確認。`00001KV4ZDMV1` 完了により blocker は解消。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`1d21aae3` 上。 +- Visible Pods: `yoi-coder-00001KV4YAAVY` running。source/logical surface は single-Pod View selection であり、本 Ticket と直接競合しない。 + +IntentPacket: + +Intent: +- Workspace Panel の Ticket 2行 row と Ticket-associated Intake Pod row の visual hierarchy / readability を改善し、親子関係・primary/secondary 情報・選択対象が読み取りやすい表示にする。 + +Binding decisions / invariants: +- Ticket lifecycle state / relation gate semantics は変更しない。 +- persisted `waiting` state や新しい Ticket schema は追加しない。 +- local Pod assignment / Pod name / socket / claim state / runtime status を git-tracked Ticket metadata/frontmatter/thread に保存しない。 +- automatic polling / automatic Intake spawn は追加しない。 +- Ticket と Intake Pod の関係を 1:1 と仮定しない。 +- selected arbitrary Pod direct-send UX を復活させない。 +- 表示改善のために lifecycle action authority boundary を緩めない。 +- 色だけに依存せず、indentation / marker / label 等で minimum relationship を示す。 + +Requirements / acceptance criteria: +- Ticket 2行 row で primary line と secondary line の強弱が確認できる。 +- Ticket-associated Intake Pod row が隣接 Ticket の child/related row として認識できる。 +- Intake Pod row が Ticket 本体や別 Ticket に見えない。 +- selected Ticket row / selected Intake Pod row の見え方が明確で、操作対象が混同されない。 +- `ready`, `planning`, `queued/inprogress`, `done/closed`, `ready+waiting` の Ticket row で readability が維持される。 +- Intake Pod `live` / `restorable` / `stale` status が確認できる。 +- pre-Ticket Intake Pod を特定 Ticket child row のように誤表示しない。 +- mouse click / keyboard selection semantics を壊さない。 +- Focused tests で row rendering contract または ViewModel/row ordering/selection contract を確認する。 + +Implementation latitude: +- dim / bold / color / prefix / indentation / separator / role chip / status chip 等は実装判断。 +- `PanelRowKind::Ticket` / `PanelRowKind::TicketIntakePod` rendering 調整可。 +- 必要なら shared row style helper を整理可。 +- Very small terminal では primary state/title と action safety を優先して bounded rendering する。 + +Escalate if: +- Panel selection model / keyboard semantics の大幅変更が必要。 +- Ticket row と Intake Pod row の action identity が曖昧になり誤操作リスクが出る。 +- 色/装飾だけでは accessibility / terminal theme 上の問題を避けられない。 +- Row rendering のために local role/session registry や Ticket schema 変更が必要。 +- bounded row rendering を維持できず大量 Ticket/Pod で一覧が読みにくくなる。 + +Validation: +- `cargo test -p tui workspace_panel --lib` +- 関連箇所に応じて `cargo test -p tui multi_pod --lib` / `cargo test -p tui row_hit_testing --lib` / `cargo test -p tui mouse_click --lib` +- `cargo fmt --check` +- `git diff --check` +- 可能なら `yoi panel` / PTY 目視または既存 Panel E2E 更新。 + +Critical risks / reviewer focus: +- visual hierarchy improves readability without changing action identity。 +- Ticket vs Intake child row distinction remains non-color-dependent。 +- selection highlight and child grouping remain understandable。 +- lifecycle authority and local role/session registry boundaries unchanged。 +- bounded rendering/narrow terminal behavior。 +- recently merged Alt+Enter and invalid Ticket placeholder behavior not regressed。 + +--- + + + +## State changed + +Routing decision と accepted implementation plan を記録済み。先行 `00001KV4ZDMV1` は merge/validation/done 済みで prior conflict/waiting reason は解消。blocking relation / unresolved orchestration-plan blocker はないため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。 + +--- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KV4ZPAD3`: + +Commit: +- `f3b435e7 fix: clarify panel ticket row hierarchy` + +Changed files: +- `crates/tui/src/multi_pod.rs` +- `crates/tui/src/workspace_panel.rs` + +Visual hierarchy decisions: +- Ticket rows now use a clearer two-line structure: + - primary line uses selected `▶ ` marker and carries state + title + - secondary line uses `meta` label and selected `│ ` marker, making id/gate/action information clearly secondary +- Ticket-associated Intake Pod rows now render as adjacent child rows: + - non-selected child marker: ` └ ` + - selected child marker: ` ▶ ` + - `live` / `restorable` / `stale` remains visible in status column + - title is `Intake Pod: ` without embedding tree glyphs in the ViewModel title +- Selected Intake Pod status line now says `selected Intake Pod ...` instead of generic selected Pod wording. +- No Ticket lifecycle, relation gate, action authority, persisted schema, polling, or spawn semantics were changed. + +Focused tests added/updated: +- Ticket row rendering asserts primary vs secondary line structure. +- Intake child row rendering asserts single-line child indentation/marker/status/title placement. +- Selected Intake Pod target status asserts it is visually described as Intake-related, not generic Ticket or Pod. + +Validation reported by coder: +- Passed: `cargo test -p tui workspace_panel --lib` +- Passed: `cargo test -p tui panel_ticket --lib` +- Passed: `cargo test -p tui selected_ticket_intake_child_status --lib` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` +- Ran: `cargo test -p tui multi_pod --lib` + - Failed in pre-existing/unrelated workspace context tests: + - `tests::workspace_panel_spawns_orchestrator_with_workspace_context` + - `tests::workspace_panel_spawns_panel_orchestrator_with_workspace_context` + - Assertion mismatch concerns expected `/workspace` vs temp test workspace paths, not row rendering/selection changes. + +Residual risks: +- No interactive `yoi panel` PTY session was run; visual evidence is covered by focused render/string tests. +- Full `multi_pod` suite remains blocked by unrelated existing failures noted above. + +--- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Reviewed diff `79dda10d..f3b435e7`. +- Changed files limited to: + - `crates/tui/src/multi_pod.rs` + - `crates/tui/src/workspace_panel.rs` +- Worktree was clean and on expected branch/commit: + - `impl/00001KV4ZPAD3-panel-row-hierarchy` + - `f3b435e7 fix: clarify panel ticket row hierarchy` + +Acceptance review: +- Ticket two-line rows now have clearer hierarchy: + - primary line uses selected marker `▶ ` plus state/title; + - detail line uses `│ meta ` when selected and `meta` label for secondary metadata. +- Ticket-associated Intake Pod rows now render as child rows with indentation plus `└` / indented `▶` and `Intake Pod: ` labeling. +- Relationship is not color-only; indentation/marker/label communicate hierarchy. +- Selected Ticket vs selected Intake Pod are distinguishable through marker placement, label, color/style, and status text. +- `live`, `restorable`, and `stale` Intake status remains visible. +- Pre-Ticket Intake Pod behavior appears preserved; existing tests still assert pre-ticket sessions are not emitted as `TicketIntakePod` child rows. +- Mouse/keyboard action semantics appear unchanged; changes are limited to row rendering/status text. +- Ticket lifecycle transitions, relation gates, registry persistence, polling/spawn behavior, and Ticket schema were not changed. +- Invalid Ticket placeholder behavior remains non-actionable. +- Alt+Enter handling was not touched. + +Focused coverage observed: +- Ticket primary/secondary row rendering contract updated. +- Non-selected alignment/truncation tests updated for `meta`. +- Waiting gate readability remains covered. +- New child-row rendering test covers indentation, single-line behavior, status column, title label, and selected child marker. +- New selected Intake child status test confirms it is not presented as a generic Ticket or generic Pod. +- Existing adjacency/pre-ticket exclusion test remains present. + +Validation performed by reviewer: +- Passed: `git diff --check 79dda10d..f3b435e7` +- Passed: `cargo fmt --check` + +Validation not run by reviewer: +- Cargo tests were not rerun because review scope was read-only and tests write build artifacts. Coder-reported focused test results were inspected as evidence. + +Broader failure assessment: +- The reported broader `cargo test -p tui multi_pod --lib` failure appears unrelated based on diff scope; changes are confined to Panel row rendering/status presentation and tests around that contract, with no workspace-context/runtime setup logic changes. + +Conclusion: +- Approved. No blocker remains. + +--- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KV4ZPAD3-panel-row-hierarchy` +- implementation commit: `f3b435e7 fix: clarify panel ticket row hierarchy` +- merge commit: `8c00a6e9 merge: panel row hierarchy` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KV4ZPAD3`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo test -p tui workspace_panel --lib` + - 16 passed +- Passed: `cargo test -p tui panel_ticket --lib` + - 4 passed +- Passed: `cargo test -p tui selected_ticket_intake_child_status --lib` + - 1 passed +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Notes: +- Broader `cargo test -p tui multi_pod --lib` remains affected by unrelated workspace-context tests and was not used as a blocker. +- Ticket/Intake visual hierarchy improved without lifecycle/action/schema/polling/spawn semantic changes. +- Orchestrator worktree is clean after validation. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + --- diff --git a/Cargo.lock b/Cargo.lock index 76baae4e..54fe9d04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3958,6 +3958,7 @@ dependencies = [ name = "tui" version = "0.1.0" dependencies = [ + "base64", "client", "crossterm 0.28.1", "llm-worker", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 48112528..b035e62d 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -12,6 +12,7 @@ e2e-test = [] client = { workspace = true } protocol = { workspace = true } ratatui = { version = "0.30.0", features = ["scrolling-regions"] } +base64 = "0.22.1" crossterm = "0.28" tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] } serde_json = { workspace = true } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 25fb1e84..0ee24fa3 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -20,6 +20,7 @@ use crate::composer_history::{ use crate::input::InputBuffer; use crate::scroll::Scroll; use crate::task::TaskStore; +use crate::text_selection::TextSelectionState; use crate::view_mode::Mode; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -293,6 +294,10 @@ pub struct App { /// `[Session TaskStore snapshot]` system messages — no protocol /// surface added on the Pod side. pub task_store: TaskStore, + /// Transient single-Pod transcript text selection. This is viewport-local + /// UI state only; it is never sent to the Pod, persisted, or appended to + /// session history/model context. + pub text_selection: TextSelectionState, /// Whether the right-side task pane is currently open. pub task_pane_open: bool, /// Top entry index of the task pane's visible window. Clamped on @@ -351,6 +356,7 @@ impl App { rewind_refresh_fence: false, greeting: None, task_store: TaskStore::new(), + text_selection: TextSelectionState::default(), task_pane_open: false, task_pane_scroll: 0, queued_inputs: VecDeque::new(), diff --git a/crates/tui/src/composer_keys.rs b/crates/tui/src/composer_keys.rs index b75c2a94..fe72e7b2 100644 --- a/crates/tui/src/composer_keys.rs +++ b/crates/tui/src/composer_keys.rs @@ -93,6 +93,21 @@ mod tests { ); } + #[test] + fn maps_alt_enter_to_newline() { + assert_eq!( + composer_edit_action(modified(KeyCode::Enter, KeyModifiers::ALT)), + Some(ComposerEditAction::InsertNewline) + ); + assert_eq!( + composer_edit_action(modified( + KeyCode::Enter, + KeyModifiers::ALT | KeyModifiers::CONTROL, + )), + None + ); + } + #[test] fn leaves_enter_tab_esc_and_control_letters_for_callers() { assert_eq!(composer_edit_action(key(KeyCode::Enter)), None); diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 579d5f25..bfd3321d 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -18,6 +18,7 @@ pub mod setup_model; mod single_pod; mod spawn; mod task; +mod text_selection; mod tool; mod ui; mod view_mode; diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 63c0da1c..52db77f8 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -2134,6 +2134,14 @@ impl MultiPodApp { }; } + let composer_action = composer_edit_action(key); + if let Some(action) = composer_action { + if action.is_modifier_action() { + self.apply_composer_edit_action(action); + return MultiPodAction::None; + } + } + match key.code { KeyCode::F(2) => { if self.panel_diagnostic.is_some() { @@ -2199,11 +2207,12 @@ impl MultiPodApp { .prepare_companion_send() .map(MultiPodAction::SendCompanion) .unwrap_or(MultiPodAction::None), - _ if composer_edit_action(key).is_some() => { - self.apply_composer_edit_action(composer_edit_action(key).expect("checked above")); + _ => { + if let Some(action) = composer_action { + self.apply_composer_edit_action(action); + } MultiPodAction::None } - _ => MultiPodAction::None, } } } @@ -5212,7 +5221,7 @@ const POD_STATUS_COLUMN_WIDTH: usize = 18; fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec> { if row.kind == PanelRowKind::TicketIntakePod { - vec![panel_row_title_line(row, selected, width)] + vec![panel_intake_child_line(row, selected, width)] } else { vec![ panel_row_title_line(row, selected, width), @@ -5232,7 +5241,7 @@ fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'sta let mut spans = Vec::new(); let mut remaining = width as usize; - push_ticket_marker_span(&mut spans, selected, &mut remaining); + push_ticket_primary_marker_span(&mut spans, selected, &mut remaining); push_column_span( &mut spans, &row.status, @@ -5245,11 +5254,41 @@ fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'sta Line::from(spans) } +fn panel_intake_child_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { + let title_style = if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + let mut spans = Vec::new(); + let mut remaining = width as usize; + + push_intake_child_marker_span(&mut spans, selected, &mut remaining); + push_column_span( + &mut spans, + &row.status, + TICKET_STATE_COLUMN_WIDTH, + intake_status_style(&row.status), + &mut remaining, + ); + push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining); + + Line::from(spans) +} + fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { let mut spans = Vec::new(); let mut remaining = width as usize; - push_ticket_marker_span(&mut spans, selected, &mut remaining); + push_ticket_detail_marker_span(&mut spans, selected, &mut remaining); + push_bounded_span( + &mut spans, + "meta ", + Style::default().fg(Color::DarkGray), + &mut remaining, + ); push_bounded_span( &mut spans, &panel_ticket_detail(row), @@ -5260,10 +5299,14 @@ fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'st Line::from(spans) } -fn push_ticket_marker_span(spans: &mut Vec>, selected: bool, remaining: &mut usize) { +fn push_ticket_primary_marker_span( + spans: &mut Vec>, + selected: bool, + remaining: &mut usize, +) { let (marker, style) = if selected { ( - "| ", + "▶ ", Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), @@ -5274,6 +5317,42 @@ fn push_ticket_marker_span(spans: &mut Vec>, selected: bool, remai push_bounded_span(spans, marker, style, remaining); } +fn push_ticket_detail_marker_span( + spans: &mut Vec>, + selected: bool, + remaining: &mut usize, +) { + let (marker, style) = if selected { + ( + "│ ", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ) + } else { + (" ", Style::default().fg(Color::DarkGray)) + }; + push_bounded_span(spans, marker, style, remaining); +} + +fn push_intake_child_marker_span( + spans: &mut Vec>, + selected: bool, + remaining: &mut usize, +) { + let (marker, style) = if selected { + ( + " ▶ ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else { + (" └ ", Style::default().fg(Color::DarkGray)) + }; + push_bounded_span(spans, marker, style, remaining); +} + fn panel_ticket_detail(row: &PanelRow) -> String { if row.kind == PanelRowKind::InvalidTicket { let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()]; @@ -5411,6 +5490,15 @@ fn panel_priority_style(priority: ActionPriority) -> Style { } } +fn intake_status_style(status: &str) -> Style { + match status { + "live" => Style::default().fg(Color::Green), + "restorable" => Style::default().fg(Color::Yellow), + "stale" => Style::default().fg(Color::DarkGray), + _ => Style::default().fg(Color::Cyan), + } +} + fn section_rows( list: &PodList, section: &MultiPodSection, @@ -5544,6 +5632,34 @@ fn target_status_line(app: &MultiPodApp) -> Line<'static> { )); } Line::from(spans) + } else if let Some(row) = app + .selected_panel_row() + .filter(|row| row.kind == PanelRowKind::TicketIntakePod) + { + let ticket_id = panel_ticket_reference(row); + let action = if row.next_action == Some(NextUserAction::OpenPod) { + "open/attach" + } else { + "unavailable" + }; + Line::from(vec![ + Span::styled("composer target ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " · selected Intake Pod ", + Style::default().fg(Color::DarkGray), + ), + Span::styled(row.status.clone(), intake_status_style(&row.status)), + Span::styled( + format!(" · Ticket {ticket_id} · blank Enter {action}"), + Style::default().fg(Color::DarkGray), + ), + ]) } else if let Some(entry) = app.selected_pod_entry() { let (status, status_style) = row_status_label(entry); Line::from(vec![ @@ -7389,9 +7505,8 @@ branch = "orchestration/custom-panel" let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; let row_id = row.ticket.as_ref().unwrap().id.as_str(); - assert!(title_line.starts_with("| ")); - assert!(detail_line.starts_with("| ")); - assert!(!title_line.starts_with("▶")); + assert!(title_line.starts_with("▶ ")); + assert!(detail_line.starts_with("│ meta ")); assert!(!title_line.contains(row_id)); assert_eq!(display_column(&title_line, "inprogress"), state_start); assert_eq!( @@ -7421,7 +7536,7 @@ branch = "orchestration/custom-panel" let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; assert!(title_line.starts_with(" ready")); - assert!(detail_line.starts_with(" 00001KTTB479X")); + assert!(detail_line.starts_with(" meta 00001KTTB479X")); assert_eq!(display_column(&title_line, "ready"), state_start); assert_eq!( display_column(&title_line, "Long Ticket title"), @@ -7449,7 +7564,7 @@ branch = "orchestration/custom-panel" assert_eq!(display_column(&title_line, "Very long Ticket"), title_start); assert!(title_line.ends_with('…')); assert_eq!(detail_line.width(), 42); - assert!(detail_line.starts_with(" 00001KTTB479X · Gate: clear")); + assert!(detail_line.starts_with(" meta 00001KTTB479X · Gate: clear")); assert!(detail_line.ends_with('…')); } @@ -7474,6 +7589,67 @@ branch = "orchestration/custom-panel" assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1")); } + #[test] + fn panel_ticket_intake_child_rows_render_as_indented_single_line() { + let row = panel_test_intake_child_row( + "00001TICKET", + "intake-live", + TicketLocalClaimStatus::Live, + Some(NextUserAction::OpenPod), + ); + + let lines = panel_row_lines(&row, false, 160); + assert_eq!(lines.len(), 1); + let line = plain_line(&lines[0]); + let status_start = 4; + let title_start = status_start + TICKET_STATE_COLUMN_WIDTH + 1; + + assert!(line.starts_with(" └ live")); + assert_eq!(display_column(&line, "live"), status_start); + assert_eq!( + display_column(&line, "Intake Pod: intake-live"), + title_start + ); + assert!(!line.starts_with(" live")); + + let selected_line = plain_line(&panel_row_lines(&row, true, 160)[0]); + assert!(selected_line.starts_with(" ▶ live")); + assert!(selected_line.contains("Intake Pod: intake-live")); + } + + #[test] + fn selected_ticket_intake_child_status_is_not_rendered_as_generic_ticket_or_pod() { + let ticket_id = "00001TICKET"; + let pod_name = "intake-live"; + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.rows.push(panel_test_intake_child_row( + ticket_id, + pod_name, + TicketLocalClaimStatus::Live, + Some(NextUserAction::OpenPod), + )); + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info(pod_name, PodStatus::Idle)], + None, + 10, + ); + let mut app = app_with_panel(list, panel); + app.select_panel_key(PanelRowKey::TicketIntakePod { + ticket_id: ticket_id.to_string(), + pod_name: pod_name.to_string(), + }); + + let status = plain_line(&target_status_line(&app)); + + assert!(status.contains("selected Intake Pod live")); + assert!(status.contains("Ticket 00001TICKET")); + assert!(status.contains("blank Enter open/attach")); + assert!(!status.contains("selected Ticket")); + assert!(!status.contains("selected Pod live")); + } + #[test] fn panel_pod_rows_use_aligned_columns_before_pod_name() { let app = test_app(vec![ @@ -7983,6 +8159,64 @@ branch = "orchestration/custom-panel" ); } + #[test] + fn multi_alt_enter_inserts_newline_without_companion_send() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("first line"); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), + MultiPodAction::None + )); + + assert_eq!(input_text(&app), "first line\n"); + assert!(!app.sending); + assert!(app.notice.is_none()); + } + + #[test] + fn multi_alt_enter_on_blank_pod_selection_inserts_newline_without_opening() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + let selected_before = app.selected_row.clone(); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), + MultiPodAction::None + )); + + assert_eq!(input_text(&app), "\n"); + assert_eq!(app.selected_row, selected_before); + assert!(app.notice.is_none()); + } + + #[test] + fn multi_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.rows.push(panel_test_ticket_row( + "TICKET-1", + "Ready", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + let mut app = app_with_panel( + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), + panel, + ); + let selected_before = app.selected_row.clone(); + assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue)); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), + MultiPodAction::None + )); + + assert_eq!(input_text(&app), "\n"); + assert_eq!(app.selected_row, selected_before); + assert!(!app.sending); + assert!(app.notice.is_none()); + } + #[test] fn multi_composer_shared_word_motion_and_delete_keys() { let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); @@ -8683,6 +8917,36 @@ branch = "orchestration/custom-panel" } } + fn panel_test_intake_child_row( + ticket_id: &str, + pod_name: &str, + status: TicketLocalClaimStatus, + next_action: Option, + ) -> PanelRow { + PanelRow { + key: PanelRowKey::TicketIntakePod { + ticket_id: ticket_id.to_string(), + pod_name: pod_name.to_string(), + }, + kind: PanelRowKind::TicketIntakePod, + title: format!("Intake Pod: {pod_name}"), + subtitle: Some(format!("Intake claim for Ticket {ticket_id}")), + status: status.label().to_string(), + priority: match status { + TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => { + ActionPriority::ActiveWork + } + TicketLocalClaimStatus::Stale => ActionPriority::Background, + }, + next_action, + ticket: None, + related_pods: vec![pod_name.to_string()], + disabled_reason: (status == TicketLocalClaimStatus::Stale) + .then(|| "claim metadata is stale".to_string()), + key_hint: Some(format!("Ticket {ticket_id} Intake Pod {pod_name}")), + } + } + fn closed_list(count: usize, selected: Option<&str>) -> PodList { PodList::from_sources( PodVisibilitySource::ResumePicker, diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 8a423538..93e16b15 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -10,8 +10,8 @@ use std::thread; use std::time::Duration; use crossterm::event::{ - self, DisableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent, - MouseEventKind, + self, DisableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, + MouseEvent, MouseEventKind, }; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{Command, execute}; @@ -23,6 +23,7 @@ use ratatui::backend::CrosstermBackend; use session_store::SegmentId; use tokio::sync::mpsc; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use client::{PodClient, PodRuntimeCommand}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; @@ -33,20 +34,19 @@ use crate::{multi_pod, picker, spawn, ui}; type FullscreenTerminal = Terminal>; -/// Enable the narrowest standard xterm mouse mode that still reports wheel -/// events. Crossterm's `EnableMouseCapture` also enables button-event -/// tracking (`?1002h`), which requests drag-motion reports and interferes -/// with terminal text selection more aggressively. Normal tracking (`?1000h`) -/// reports button presses, releases, and wheel notches, but does not request -/// drag-motion reports; the TUI ignores the non-wheel events. +/// Enable SGR coordinates plus button-event tracking for Yoi-owned drag text +/// selection in the single-Pod transcript. This intentionally opts out of +/// terminal-native selection while the alternate screen is active, but still +/// avoids all-motion tracking (`?1003h`). #[derive(Debug, Clone, Copy)] -struct EnableWheelMouseCapture; +struct EnableSinglePodMouseCapture; -impl Command for EnableWheelMouseCapture { +impl Command for EnableSinglePodMouseCapture { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - // 1000: normal mouse tracking (includes wheel button presses) // 1006: SGR extended coordinates used by crossterm's parser - f.write_str("\x1B[?1000h\x1B[?1006h") + // 1000: normal mouse tracking (button presses/releases and wheel) + // 1002: button-event tracking (drag reports while a button is held) + f.write_str("\x1B[?1006h\x1B[?1000h\x1B[?1002h") } #[cfg(windows)] @@ -60,6 +60,45 @@ impl Command for EnableWheelMouseCapture { } } +fn copy_to_terminal_clipboard(out: &mut W, text: &str) -> io::Result<()> { + let encoded = BASE64_STANDARD.encode(text.as_bytes()); + write!(out, "\x1B]52;c;{}\x07", encoded)?; + out.flush() +} + +fn copy_selection_to_writer(app: &mut App, out: &mut W) -> bool { + let Some(text) = app.text_selection.copy_text() else { + return false; + }; + + let result = copy_to_terminal_clipboard(out, &text); + app.text_selection.clear(); + match result { + Ok(()) => { + app.flash_actionbar_notice( + "Copied selected text to terminal clipboard.", + ActionbarNoticeLevel::Info, + ActionbarNoticeSource::Tui, + Duration::from_secs(3), + ); + } + Err(_) => { + app.flash_actionbar_notice( + "Copy failed: terminal clipboard write failed.", + ActionbarNoticeLevel::Error, + ActionbarNoticeSource::Tui, + Duration::from_secs(5), + ); + } + } + true +} + +fn copy_selection_to_terminal(app: &mut App) -> bool { + let mut stdout = io::stdout(); + copy_selection_to_writer(app, &mut stdout) +} + fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { if let Some(p) = override_path { return p; @@ -294,10 +333,9 @@ pub(crate) async fn run_spawn( fn enter_fullscreen() -> Result> { let mut stdout = io::stdout(); - // Enable only normal mouse tracking for wheel events. Avoid crossterm's - // full mouse capture because it requests drag-motion events and breaks - // terminal-native text selection. - execute!(stdout, EnterAlternateScreen, EnableWheelMouseCapture)?; + // Enable button-event tracking so the transcript can own drag selection; + // avoid all-motion capture because hover-motion reports are unnecessary. + execute!(stdout, EnterAlternateScreen, EnableSinglePodMouseCapture)?; let backend = CrosstermBackend::new(stdout); Ok(Terminal::new(backend)?) } @@ -310,7 +348,7 @@ fn enter_fullscreen_existing( execute!( terminal.backend_mut(), EnterAlternateScreen, - EnableWheelMouseCapture + EnableSinglePodMouseCapture )?; Ok(()) } @@ -761,8 +799,25 @@ const PANE_SCROLL_LINES: usize = 5; fn handle_mouse(app: &mut App, mouse: MouseEvent) { match mouse.kind { - MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES), - MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES), + MouseEventKind::ScrollUp => { + app.text_selection.clear(); + app.scroll.scroll_up(WHEEL_LINES); + } + MouseEventKind::ScrollDown => { + app.text_selection.clear(); + app.scroll.scroll_down(WHEEL_LINES); + } + MouseEventKind::Down(MouseButton::Left) if app.rewind_picker.is_none() => { + if !app.text_selection.begin_drag(mouse.column, mouse.row) { + app.text_selection.clear(); + } + } + MouseEventKind::Drag(MouseButton::Left) if app.rewind_picker.is_none() => { + app.text_selection.update_drag(mouse.column, mouse.row); + } + MouseEventKind::Up(MouseButton::Left) if app.rewind_picker.is_none() => { + app.text_selection.finish_drag(mouse.column, mouse.row); + } _ => {} } } @@ -983,6 +1038,25 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { } } + if key.modifiers.is_empty() { + match key.code { + KeyCode::Esc if app.text_selection.clear() => return None, + KeyCode::Char('y') if app.text_selection.has_selection() => { + if !copy_selection_to_terminal(app) { + app.text_selection.clear(); + app.flash_actionbar_notice( + "Selection contains no copyable text.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + Duration::from_secs(3), + ); + } + return None; + } + _ => {} + } + } + match key.code { KeyCode::Esc => { // Close the popup if it's still showing (covers the @@ -1128,18 +1202,116 @@ fn handle_pause_or_quit(app: &mut App) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::text_selection::{HistoryViewport, SelectionRow}; use protocol::{Event, RewindTarget, RewindTargetId, Segment}; #[test] - fn wheel_mouse_capture_uses_normal_tracking_without_drag_capture() { + fn single_pod_mouse_capture_enables_drag_without_all_motion() { let mut ansi = String::new(); - Command::write_ansi(&EnableWheelMouseCapture, &mut ansi).unwrap(); + Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap(); - assert_eq!(ansi, "\x1B[?1000h\x1B[?1006h"); - assert!(!ansi.contains("?1002h")); + assert!(ansi.contains("?1000h")); + assert!(ansi.contains("?1002h")); + assert!(ansi.contains("?1006h")); assert!(!ansi.contains("?1003h")); } + #[test] + fn mouse_drag_updates_selection_state() { + let mut app = App::new("pod".into()); + app.text_selection.set_history_snapshot( + HistoryViewport { + x: 1, + y: 2, + width: 20, + height: 3, + top_offset: 0, + total_lines: 1, + }, + vec![SelectionRow::new("alpha".into(), true)], + ); + + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::NONE, + }, + ); + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 4, + row: 2, + modifiers: KeyModifiers::NONE, + }, + ); + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 4, + row: 2, + modifiers: KeyModifiers::NONE, + }, + ); + + assert_eq!(app.text_selection.copy_text().as_deref(), Some("lph")); + assert!(!app.text_selection.active().unwrap().dragging); + } + + #[test] + fn esc_clears_selection_without_editing_composer() { + let mut app = App::new("pod".into()); + app.text_selection.set_history_snapshot( + HistoryViewport { + x: 0, + y: 0, + width: 10, + height: 1, + top_offset: 0, + total_lines: 1, + }, + vec![SelectionRow::new("hello".into(), true)], + ); + assert!(app.text_selection.begin_drag(0, 0)); + + assert!(handle_key(&mut app, key(KeyCode::Esc)).is_none()); + assert!(!app.text_selection.has_selection()); + assert!(app.input.is_empty()); + } + + #[test] + fn copy_selection_writes_osc52_and_clears_selection() { + let mut app = App::new("pod".into()); + app.text_selection.set_history_snapshot( + HistoryViewport { + x: 0, + y: 0, + width: 10, + height: 1, + top_offset: 0, + total_lines: 1, + }, + vec![SelectionRow::new("hello".into(), true)], + ); + assert!(app.text_selection.begin_drag(0, 0)); + assert!(app.text_selection.update_drag(4, 0)); + + let mut out = Vec::new(); + assert!(copy_selection_to_writer(&mut app, &mut out)); + + assert_eq!(String::from_utf8(out).unwrap(), "\x1B]52;c;aGVsbG8=\x07"); + assert!(!app.text_selection.has_selection()); + assert!( + app.current_actionbar_notice(std::time::Instant::now()) + .is_some() + ); + } + #[tokio::test] async fn terminal_event_is_selected_before_ready_pod_event() { let (tx, mut rx) = mpsc::unbounded_channel(); diff --git a/crates/tui/src/text_selection.rs b/crates/tui/src/text_selection.rs new file mode 100644 index 00000000..3be406a2 --- /dev/null +++ b/crates/tui/src/text_selection.rs @@ -0,0 +1,367 @@ +//! Local, non-persistent text selection state for the single-Pod transcript view. +//! +//! This module deliberately stores only the most recent rendered history rows and +//! the active drag endpoints. Selected/copied text never leaves TUI-local state +//! unless the user explicitly presses the copy key, and even then the caller is +//! responsible for using a non-history clipboard path. + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectionPoint { + pub row: usize, + pub col: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HistoryViewport { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, + pub top_offset: usize, + pub total_lines: usize, +} + +impl HistoryViewport { + pub fn contains(self, col: u16, row: u16) -> bool { + col >= self.x + && col < self.x.saturating_add(self.width) + && row >= self.y + && row < self.y.saturating_add(self.height) + } + + pub fn point_at(self, col: u16, row: u16) -> Option { + if !self.contains(col, row) { + return None; + } + Some(SelectionPoint { + row: self.top_offset + row.saturating_sub(self.y) as usize, + col: col.saturating_sub(self.x) as usize, + }) + } + + pub fn clamped_point_at(self, col: u16, row: u16) -> Option { + if self.width == 0 || self.height == 0 || self.total_lines == 0 { + return None; + } + let max_col = self.x.saturating_add(self.width.saturating_sub(1)); + let max_row = self.y.saturating_add(self.height.saturating_sub(1)); + let col = col.clamp(self.x, max_col); + let row = row.clamp(self.y, max_row); + let absolute_row = self.top_offset + row.saturating_sub(self.y) as usize; + if absolute_row >= self.total_lines { + return None; + } + Some(SelectionPoint { + row: absolute_row, + col: col.saturating_sub(self.x) as usize, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionRow { + pub text: String, + /// Whether this rendered row belongs to selectable transcript text. Tool + /// calls, thinking blocks, greetings, notices, stats, and other non-text + /// items are intentionally false. + pub selectable: bool, +} + +impl SelectionRow { + pub fn new(text: String, selectable: bool) -> Self { + Self { text, selectable } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActiveSelection { + pub anchor: SelectionPoint, + pub focus: SelectionPoint, + pub dragging: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct TextSelectionState { + active: Option, + viewport: Option, + rows: Vec, +} + +impl TextSelectionState { + pub fn set_history_snapshot(&mut self, viewport: HistoryViewport, rows: Vec) { + let rows_changed = self.rows != rows; + self.viewport = Some(viewport); + self.rows = rows; + if rows_changed { + self.active = None; + } else { + self.drop_selection_if_stale(); + } + } + + pub fn clear_history_snapshot(&mut self) { + self.viewport = None; + self.rows.clear(); + self.active = None; + } + + #[cfg(test)] + pub fn active(&self) -> Option { + self.active + } + + pub fn has_selection(&self) -> bool { + self.active.is_some() + } + + pub fn clear(&mut self) -> bool { + self.active.take().is_some() + } + + pub fn begin_drag(&mut self, col: u16, row: u16) -> bool { + let Some(point) = self + .viewport + .and_then(|viewport| viewport.point_at(col, row)) + else { + return false; + }; + if !self.row_is_selectable(point.row) { + self.active = None; + return false; + } + self.active = Some(ActiveSelection { + anchor: point, + focus: point, + dragging: true, + }); + true + } + + pub fn update_drag(&mut self, col: u16, row: u16) -> bool { + let Some(mut active) = self.active else { + return false; + }; + if !active.dragging { + return false; + } + let Some(point) = self + .viewport + .and_then(|viewport| viewport.clamped_point_at(col, row)) + else { + return false; + }; + active.focus = point; + self.active = Some(active); + true + } + + pub fn finish_drag(&mut self, col: u16, row: u16) -> bool { + let updated = self.update_drag(col, row); + if let Some(active) = self.active.as_mut() { + active.dragging = false; + true + } else { + updated + } + } + + pub fn copy_text(&self) -> Option { + let active = self.active?; + selected_text_from_rows(&self.rows, active.anchor, active.focus) + } + + pub fn range_for_row(&self, row: usize) -> Option<(usize, usize)> { + let active = self.active?; + let (start, end) = ordered_points(active.anchor, active.focus); + if row < start.row || row > end.row || !self.row_is_selectable(row) { + return None; + } + let row_width = display_width(&self.rows.get(row)?.text); + let (from, to) = if start.row == end.row { + ( + start.col.min(row_width), + end.col.saturating_add(1).min(row_width), + ) + } else if row == start.row { + (start.col.min(row_width), row_width) + } else if row == end.row { + (0, end.col.saturating_add(1).min(row_width)) + } else { + (0, row_width) + }; + if from >= to { None } else { Some((from, to)) } + } + + fn row_is_selectable(&self, row: usize) -> bool { + self.rows.get(row).is_some_and(|row| row.selectable) + } + + fn drop_selection_if_stale(&mut self) { + let Some(active) = self.active else { + return; + }; + if active.anchor.row >= self.rows.len() || active.focus.row >= self.rows.len() { + self.active = None; + } + } +} + +pub fn selected_text_from_rows( + rows: &[SelectionRow], + anchor: SelectionPoint, + focus: SelectionPoint, +) -> Option { + let (start, end) = ordered_points(anchor, focus); + if start.row >= rows.len() || end.row >= rows.len() { + return None; + } + + let mut copied = Vec::new(); + for row_idx in start.row..=end.row { + let row = &rows[row_idx]; + if !row.selectable { + continue; + } + let row_width = display_width(&row.text); + let (from, to) = if start.row == end.row { + ( + start.col.min(row_width), + end.col.saturating_add(1).min(row_width), + ) + } else if row_idx == start.row { + (start.col.min(row_width), row_width) + } else if row_idx == end.row { + (0, end.col.saturating_add(1).min(row_width)) + } else { + (0, row_width) + }; + copied.push(slice_display_cols(&row.text, from, to)); + } + + if copied.iter().any(|line| !line.is_empty()) { + Some(copied.join("\n")) + } else { + None + } +} + +pub fn ordered_points(a: SelectionPoint, b: SelectionPoint) -> (SelectionPoint, SelectionPoint) { + if (a.row, a.col) <= (b.row, b.col) { + (a, b) + } else { + (b, a) + } +} + +pub fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +pub fn slice_display_cols(text: &str, start: usize, end: usize) -> String { + if start >= end { + return String::new(); + } + + let mut out = String::new(); + let mut col = 0usize; + for c in text.chars() { + let width = UnicodeWidthChar::width(c).unwrap_or(0); + let next = col.saturating_add(width); + if next > start && col < end { + out.push(c); + } + col = next; + if col >= end { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinate_mapping_uses_history_inner_origin_and_scroll_offset() { + let viewport = HistoryViewport { + x: 2, + y: 3, + width: 10, + height: 5, + top_offset: 7, + total_lines: 20, + }; + + assert_eq!( + viewport.point_at(4, 5), + Some(SelectionPoint { row: 9, col: 2 }) + ); + assert_eq!(viewport.point_at(1, 5), None); + assert_eq!(viewport.point_at(4, 8), None); + } + + #[test] + fn selection_state_drag_update_and_clear() { + let mut state = TextSelectionState::default(); + state.set_history_snapshot( + HistoryViewport { + x: 0, + y: 0, + width: 20, + height: 3, + top_offset: 0, + total_lines: 3, + }, + vec![ + SelectionRow::new("alpha".into(), true), + SelectionRow::new("tool".into(), false), + SelectionRow::new("omega".into(), true), + ], + ); + + assert!(state.begin_drag(1, 0)); + assert!(state.update_drag(2, 2)); + assert_eq!(state.copy_text().as_deref(), Some("lpha\nome")); + assert!(state.finish_drag(2, 2)); + assert!(!state.active().unwrap().dragging); + assert!(state.clear()); + assert!(!state.has_selection()); + } + + #[test] + fn selected_text_preserves_blank_separator_between_text_items() { + let rows = vec![ + SelectionRow::new("first".into(), true), + SelectionRow::new("".into(), true), + SelectionRow::new("second".into(), true), + ]; + + let copied = selected_text_from_rows( + &rows, + SelectionPoint { row: 0, col: 0 }, + SelectionPoint { row: 2, col: 5 }, + ) + .unwrap(); + assert_eq!(copied, "first\n\nsecond"); + } + + #[test] + fn non_text_rows_are_skipped_during_extraction() { + let rows = vec![ + SelectionRow::new("user".into(), true), + SelectionRow::new("tool output".into(), false), + SelectionRow::new("assistant".into(), true), + ]; + + let copied = selected_text_from_rows( + &rows, + SelectionPoint { row: 0, col: 0 }, + SelectionPoint { row: 2, col: 8 }, + ) + .unwrap(); + assert_eq!(copied, "user\nassistant"); + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index c83306d1..4f05744a 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -31,6 +31,7 @@ use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; use crate::command::CommandCandidate; use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; +use crate::text_selection::{HistoryViewport, SelectionRow}; use crate::view_mode::Mode; pub fn draw(frame: &mut Frame, app: &mut App) { @@ -306,55 +307,90 @@ fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) - needed.clamp(1, cap) } +pub struct HistoryRow { + pub line: Line<'static>, + pub text: String, + pub selectable: bool, +} + +impl HistoryRow { + fn new(line: Line<'static>, selectable: bool) -> Self { + let text = line_text(&line); + Self { + line, + text, + selectable, + } + } + + fn selection_row(&self) -> SelectionRow { + SelectionRow::new(self.text.clone(), self.selectable) + } +} + /// Pre-rendered history lines plus the line indices at which each turn /// begins (used for Ctrl-[/] jumps). pub struct HistoryLayout { - pub lines: Vec>, + pub rows: Vec, pub turn_starts: Vec, } pub fn compute_history(app: &App, width: u16) -> HistoryLayout { // Step 1: collect logical lines from each block (unwrapped). - let mut logical: Vec> = Vec::new(); + let mut logical: Vec<(Line<'static>, bool)> = Vec::new(); let mut logical_turn_starts: Vec = Vec::new(); let mut first = true; + let mut previous_selectable = false; let mut i = 0; while i < app.blocks.len() { + let block = &app.blocks[i]; + let current_selectable = block_is_selectable_text(block); if !first { - logical.push(Line::from("")); + // Preserve a deterministic blank-line separator when copying + // across text-like items. Tool/non-text item rows remain + // unselectable, but separators adjacent to text are copied as + // blank lines so cross-item extraction is stable. + logical.push((Line::from(""), previous_selectable || current_selectable)); } first = false; - let block = &app.blocks[i]; if matches!(block, Block::TurnHeader { .. }) { logical_turn_starts.push(logical.len()); } if matches!(block, Block::ToolCall(_)) { let out = crate::tool::render_tool(&app.cache, &app.blocks, i, width, app.mode); - logical.extend(out.lines); + logical.extend(out.lines.into_iter().map(|line| (line, false))); i += out.consumed.max(1); + previous_selectable = false; continue; } - render_block_into(&mut logical, block, width, app.mode); + let mut block_lines = Vec::new(); + render_block_into(&mut block_lines, block, width, app.mode); + logical.extend( + block_lines + .into_iter() + .map(|line| (line, current_selectable)), + ); + previous_selectable = current_selectable; i += 1; } // Step 2: pre-wrap every logical line to char-based terminal rows so // scroll math is exact. Track the logical → wrapped mapping so // turn-start indices get translated into wrapped-row coordinates. - let mut lines: Vec> = Vec::with_capacity(logical.len()); + let mut rows: Vec = Vec::with_capacity(logical.len()); let mut logical_to_wrapped: Vec = Vec::with_capacity(logical.len() + 1); - for line in logical { - logical_to_wrapped.push(lines.len()); - wrap_line_into(line, width, &mut lines); + for (line, selectable) in logical { + logical_to_wrapped.push(rows.len()); + wrap_history_row_into(line, selectable, width, &mut rows); } - logical_to_wrapped.push(lines.len()); + logical_to_wrapped.push(rows.len()); let turn_starts = logical_turn_starts .into_iter() - .map(|i| logical_to_wrapped.get(i).copied().unwrap_or(lines.len())) + .map(|i| logical_to_wrapped.get(i).copied().unwrap_or(rows.len())) .collect(); - HistoryLayout { lines, turn_starts } + HistoryLayout { rows, turn_starts } } /// Horizontal gutter around the log area. Applied via a @@ -369,6 +405,7 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { app.scroll.total_lines = 0; app.scroll.tail_top_offset = 0; app.scroll.turn_starts.clear(); + app.text_selection.clear_history_snapshot(); return; } @@ -389,21 +426,23 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING)); let inner = outer_block.inner(history_area); if inner.width == 0 || inner.height == 0 { + app.text_selection.clear_history_snapshot(); return; } if let Some(picker) = app.rewind_picker.as_mut() { + app.text_selection.clear_history_snapshot(); draw_rewind_picker(frame, history_area, inner, outer_block, picker); return; } - let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width); + let HistoryLayout { rows, turn_starts } = compute_history(app, inner.width); - // `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll + // `rows` is already pre-wrapped: 1 entry == 1 terminal row. Scroll // math degenerates to index arithmetic. - let tail_top = lines.len().saturating_sub(inner.height as usize); + let tail_top = rows.len().saturating_sub(inner.height as usize); app.scroll.area_height = inner.height; - app.scroll.total_lines = lines.len(); + app.scroll.total_lines = rows.len(); app.scroll.tail_top_offset = tail_top; app.scroll.turn_starts = turn_starts; @@ -413,8 +452,30 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { app.scroll.top_offset = app.scroll.top_offset.min(tail_top); } - let end = (app.scroll.top_offset + inner.height as usize).min(lines.len()); - let visible: Vec> = lines[app.scroll.top_offset..end].to_vec(); + app.text_selection.set_history_snapshot( + HistoryViewport { + x: inner.x, + y: inner.y, + width: inner.width, + height: inner.height, + top_offset: app.scroll.top_offset, + total_lines: rows.len(), + }, + rows.iter().map(HistoryRow::selection_row).collect(), + ); + + let end = (app.scroll.top_offset + inner.height as usize).min(rows.len()); + let visible: Vec> = rows[app.scroll.top_offset..end] + .iter() + .enumerate() + .map(|(offset, row)| { + let absolute_row = app.scroll.top_offset + offset; + match app.text_selection.range_for_row(absolute_row) { + Some((from, to)) => highlight_line_selection(&row.line, from, to), + None => row.line.clone(), + } + }) + .collect(); // Pre-wrapped input → render without ratatui's word-wrap (which // would otherwise re-wrap mid-row at word boundaries and desync the @@ -717,6 +778,88 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec>) push_row(&mut current, &mut row_width, out); } +fn wrap_history_row_into( + line: Line<'static>, + selectable: bool, + width: u16, + out: &mut Vec, +) { + let mut wrapped = Vec::new(); + wrap_line_into(line, width, &mut wrapped); + out.extend( + wrapped + .into_iter() + .map(|line| HistoryRow::new(line, selectable)), + ); +} + +fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() +} + +fn highlight_line_selection(line: &Line<'static>, start: usize, end: usize) -> Line<'static> { + if start >= end { + return line.clone(); + } + + let highlight = Style::default().fg(Color::Black).bg(Color::Cyan); + let mut col = 0usize; + let mut spans = Vec::new(); + for span in &line.spans { + let mut plain = String::new(); + let mut selected = String::new(); + let push_pending = + |plain: &mut String, selected: &mut String, spans: &mut Vec>| { + if !plain.is_empty() { + spans.push(Span::styled(std::mem::take(plain), span.style)); + } + if !selected.is_empty() { + spans.push(Span::styled( + std::mem::take(selected), + span.style.patch(highlight), + )); + } + }; + + let mut in_selected = false; + for ch in span.content.chars() { + let width = UnicodeWidthChar::width(ch).unwrap_or(0); + let next = col.saturating_add(width); + let selected_char = next > start && col < end; + if selected_char != in_selected { + push_pending(&mut plain, &mut selected, &mut spans); + in_selected = selected_char; + } + if selected_char { + selected.push(ch); + } else { + plain.push(ch); + } + col = next; + } + push_pending(&mut plain, &mut selected, &mut spans); + } + + Line { + spans, + style: line.style, + alignment: line.alignment, + } +} + +/// Only text-like transcript Items are selectable/copyable. Tool calls and +/// other non-text Items remain visible but unselectable, so their rendered +/// diagnostics/output are never copied through this path. +fn block_is_selectable_text(block: &Block) -> bool { + matches!( + block, + Block::UserMessage { .. } | Block::SystemMessage { .. } | Block::AssistantText { .. } + ) +} + fn render_block_into(lines: &mut Vec>, block: &Block, width: u16, mode: Mode) { match block { Block::Greeting(g) => match mode { @@ -1668,4 +1811,41 @@ mod tests { Some("retrying LLM request".into()) ); } + + #[test] + fn history_rows_mark_text_items_selectable_and_non_text_unselectable() { + let mut app = App::new("pod".to_string()); + app.blocks = vec![ + Block::UserMessage { + segments: vec![Segment::Text { + content: "hello".to_string(), + }], + }, + Block::AssistantText { + text: "world".to_string(), + }, + Block::Thinking(ThinkingBlock { + text: "private reasoning".to_string(), + state: ThinkingState::Finished { elapsed_secs: None }, + }), + ]; + + let layout = compute_history(&app, 80); + let rows: Vec<_> = layout + .rows + .iter() + .map(|row| (row.text.as_str(), row.selectable)) + .collect(); + + assert!(rows.contains(&("hello", true))); + assert!(rows.contains(&("world", true))); + assert!( + rows.iter() + .any(|(text, selectable)| text.contains("private reasoning") && !*selectable) + ); + assert!( + rows.iter() + .any(|(text, selectable)| text.is_empty() && *selectable) + ); + } } diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 0044e77e..aee3fc22 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -1039,7 +1039,7 @@ fn ticket_intake_pod_row(intake: &TicketAssociatedIntakeEntry) -> PanelRow { pod_name: intake.pod_name.clone(), }, kind: PanelRowKind::TicketIntakePod, - title: format!("↳ Intake Pod: {}", intake.pod_name), + title: format!("Intake Pod: {}", intake.pod_name), subtitle: Some(format!( "Ticket {} · {} · {}", intake.ticket_id, diff --git a/package.nix b/package.nix index ced21a46..b7341665 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-XNj5cb8O4aUlrzeXF43htxhoTE3i6XGmzJpXas+jsAg="; + cargoHash = "sha256-pIDYnbBs3U8Z3IndgH10rirv8/IdFv1WlgwpCbKXy+M="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,