Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
b4d4b0712c | ||
|
|
b949cffb9e | ||
|
|
f78aa13b36 |
@@ -11,6 +11,7 @@
|
||||
//! `dispatch` / the overlay routing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -54,6 +55,36 @@ enum OverlayDecision {
|
||||
Pass,
|
||||
}
|
||||
|
||||
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
|
||||
let encoded = base64_encode(text.as_bytes());
|
||||
let mut output = std::io::stdout();
|
||||
write!(output, "\x1b]52;c;{encoded}\x07")?;
|
||||
output.flush()
|
||||
}
|
||||
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let bits = (u32::from(chunk[0]) << 16)
|
||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
|
||||
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
|
||||
output.push(if chunk.len() > 1 {
|
||||
TABLE[((bits >> 6) & 0x3f) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
output.push(if chunk.len() > 2 {
|
||||
TABLE[(bits & 0x3f) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
cfg: TuiConfig,
|
||||
home_dir: String,
|
||||
@@ -340,6 +371,10 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if self.handle_structured_mouse(&key) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(effects) = self.handle_structured_key(&key) {
|
||||
self.perform(effects).await;
|
||||
return Ok(false);
|
||||
@@ -423,6 +458,61 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
/// Transcript blocks are pointer targets: click selects the whole block,
|
||||
/// gives it the selection surface, and copies its complete text.
|
||||
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
|
||||
let InputKey::Mouse {
|
||||
kind,
|
||||
region: Region::Pane,
|
||||
row,
|
||||
..
|
||||
} = key
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
|
||||
return false;
|
||||
}
|
||||
let Some(sid) = self.state.active_session() else {
|
||||
return false;
|
||||
};
|
||||
let Some(handle) = self.structured.get(&sid) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pane) = self.region.pane else {
|
||||
return false;
|
||||
};
|
||||
let offset = usize::from(row.saturating_sub(pane.y));
|
||||
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
|
||||
if offset >= usize::from(transcript_height) {
|
||||
return true;
|
||||
}
|
||||
let mut state = handle.state.lock().expect("structured state mutex");
|
||||
let rendered_line = state.scroll.saturating_add(offset);
|
||||
if !state.select_rendered_line(rendered_line) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = state.selected.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
|
||||
return true;
|
||||
};
|
||||
if *kind == MouseKind::Hover {
|
||||
return true;
|
||||
}
|
||||
let text = crate::structured_pane::copy_text(transcript);
|
||||
drop(state);
|
||||
if text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match copy_to_terminal_clipboard(&text) {
|
||||
Ok(()) => self.state.toast("copied transcript block"),
|
||||
Err(error) => self.state.toast(format!("copy failed: {error}")),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||
/// the daemon.
|
||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||
@@ -1905,6 +1995,12 @@ async fn fetch_branches(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipboard_payload_uses_standard_base64() {
|
||||
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
|
||||
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
|
||||
}
|
||||
use crate::action::Action;
|
||||
use crate::draft::DraftField;
|
||||
use crate::keymap::Mode;
|
||||
|
||||
@@ -55,6 +55,7 @@ pub enum MouseKind {
|
||||
Click,
|
||||
DblClick,
|
||||
Drag,
|
||||
Hover,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
}
|
||||
@@ -148,6 +149,7 @@ impl InputKey {
|
||||
MouseKind::Click => "click",
|
||||
MouseKind::DblClick => "dblclick",
|
||||
MouseKind::Drag => "drag",
|
||||
MouseKind::Hover => "hover",
|
||||
MouseKind::ScrollUp => "scrollup",
|
||||
MouseKind::ScrollDown => "scrolldown",
|
||||
};
|
||||
@@ -178,6 +180,7 @@ impl InputKey {
|
||||
"click" => MouseKind::Click,
|
||||
"dblclick" => MouseKind::DblClick,
|
||||
"drag" => MouseKind::Drag,
|
||||
"hover" => MouseKind::Hover,
|
||||
"scrollup" => MouseKind::ScrollUp,
|
||||
"scrolldown" => MouseKind::ScrollDown,
|
||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||
@@ -297,6 +300,7 @@ where
|
||||
let mk = match kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
|
||||
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
|
||||
MouseEventKind::Moved => MouseKind::Hover,
|
||||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||||
_ => return None,
|
||||
@@ -681,6 +685,9 @@ mod tests {
|
||||
let s = "mouse:dblclick(sidebar-row)";
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s);
|
||||
let s = "mouse:hover(pane)";
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -308,11 +308,12 @@ fn fallback_after_removal(
|
||||
|
||||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
let mut out = Vec::new();
|
||||
for row in &state.rows {
|
||||
for (index, row) in state.rows.iter().enumerate() {
|
||||
if row.kind == TranscriptRowKind::Plan {
|
||||
continue;
|
||||
}
|
||||
let row_width = width.saturating_sub(1).max(1);
|
||||
let start = out.len();
|
||||
match row.kind {
|
||||
TranscriptRowKind::User => push_wrapped(
|
||||
&mut out,
|
||||
@@ -356,10 +357,53 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||||
),
|
||||
TranscriptRowKind::Plan => {}
|
||||
}
|
||||
if out.len() > start
|
||||
&& state.rows[index + 1..]
|
||||
.iter()
|
||||
.any(|next| next.kind != TranscriptRowKind::Plan)
|
||||
{
|
||||
// A blank surface line is the vertical padding that keeps adjacent
|
||||
// transcript blocks readable at a glance.
|
||||
out.push(VisualLine {
|
||||
text: String::new(),
|
||||
row_id: None,
|
||||
rail: false,
|
||||
tone: Tone::Normal,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text supplied to the terminal clipboard when a transcript block is
|
||||
/// clicked. It preserves the full tool detail rather than its compact preview.
|
||||
pub fn copy_text(row: &TranscriptRow) -> String {
|
||||
match row.kind {
|
||||
TranscriptRowKind::User
|
||||
| TranscriptRowKind::AgentText
|
||||
| TranscriptRowKind::AgentThought => content_text(row),
|
||||
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
|
||||
let (name, argument) = tool_title(row);
|
||||
let mut lines = vec![if argument.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{name} {argument}")
|
||||
}];
|
||||
lines.extend(tool_content_lines(row, None));
|
||||
lines.join("\n")
|
||||
}
|
||||
TranscriptRowKind::Approval => row
|
||||
.data
|
||||
.get("toolCall")
|
||||
.and_then(|tool| tool.get("title"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("permission requested")
|
||||
.into(),
|
||||
TranscriptRowKind::Notice => "[notice]".into(),
|
||||
TranscriptRowKind::Plan => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_wrapped(
|
||||
out: &mut Vec<VisualLine>,
|
||||
row: &TranscriptRow,
|
||||
@@ -1000,6 +1044,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_blocks_have_a_padding_line_between_them() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows = vec![
|
||||
row(
|
||||
"one",
|
||||
TranscriptRowKind::User,
|
||||
json!({"content":{"type":"text","text":"first"}}),
|
||||
),
|
||||
row(
|
||||
"two",
|
||||
TranscriptRowKind::AgentText,
|
||||
json!({"content":{"type":"text","text":"second"}}),
|
||||
),
|
||||
];
|
||||
|
||||
let lines = build_lines(&state, 80);
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[1].text, "");
|
||||
assert!(lines[1].row_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_tool_keeps_its_full_detail() {
|
||||
let tool = row(
|
||||
"t",
|
||||
TranscriptRowKind::ToolCall,
|
||||
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
|
||||
);
|
||||
|
||||
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_is_compact_until_selected_row_expands() {
|
||||
let mut state = StructuredState::default();
|
||||
@@ -1420,7 +1497,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
@@ -1439,7 +1516,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
@@ -1459,7 +1536,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
@@ -1478,7 +1555,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
assert!(before[0].contains("line 10"), "{before:?}");
|
||||
@@ -1497,7 +1574,7 @@ mod tests {
|
||||
let mut state = StructuredState::default();
|
||||
state.follow_selection = false;
|
||||
numbered_rows(&mut state, 20);
|
||||
state.scroll = 10;
|
||||
state.scroll = 20;
|
||||
|
||||
render_transcript_lines(&pane, &mut state, 30, 5);
|
||||
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||||
|
||||
@@ -199,6 +199,18 @@ impl StructuredState {
|
||||
self.follow_selection = true;
|
||||
}
|
||||
|
||||
/// Select the transcript block under a rendered transcript line. Pointer
|
||||
/// input uses the same row identity map as scroll reconciliation, so a
|
||||
/// wrapped block behaves as one target.
|
||||
pub fn select_rendered_line(&mut self, line: usize) -> bool {
|
||||
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
|
||||
return false;
|
||||
};
|
||||
self.selected = Some(id);
|
||||
self.follow_selection = false;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn toggle_selected(&mut self) {
|
||||
let Some(id) = self.selected.clone() else {
|
||||
return;
|
||||
@@ -657,6 +669,23 @@ mod tests {
|
||||
assert_eq!(state.composer.value(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_rendered_line_selects_its_whole_block() {
|
||||
let mut state = StructuredState::default();
|
||||
state.line_rows = vec![
|
||||
Some("one".into()),
|
||||
Some("one".into()),
|
||||
None,
|
||||
Some("two".into()),
|
||||
];
|
||||
|
||||
assert!(state.select_rendered_line(1));
|
||||
assert_eq!(state.selected.as_deref(), Some("one"));
|
||||
assert!(!state.follow_selection);
|
||||
assert!(!state.select_rendered_line(2));
|
||||
assert_eq!(state.selected.as_deref(), Some("one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
||||
let mut state = StructuredState::default();
|
||||
|
||||
+32
-23
@@ -3062,31 +3062,24 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
/* The composer is a sibling of the scrolling transcript, not part of it: it
|
||||
stays reachable at the pane edge without changing transcript scroll math. */
|
||||
stays reachable at the pane edge without changing transcript scroll math.
|
||||
It is a flush band at the foot of the pane rather than a floating card: its
|
||||
own `--surface` fill is what separates it from the transcript, over a
|
||||
hairline that doubles as the focus indicator. Inner metrics line the draft
|
||||
up with the transcript's own 12px gutter. */
|
||||
.structured-composer {
|
||||
flex: 0 0 auto;
|
||||
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
|
||||
padding: var(--sp-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-3);
|
||||
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
|
||||
var(--shadow-1);
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
transition: border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.structured-composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
|
||||
0 0 0 3px var(--accent-tint),
|
||||
var(--shadow-1);
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
|
||||
.structured-input-wrap {
|
||||
@@ -3151,6 +3144,10 @@ input.leaf-rename:focus {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.structured-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.structured-composer .structured-send,
|
||||
.structured-composer .structured-cancel {
|
||||
height: 40px;
|
||||
@@ -3208,17 +3205,29 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
/* One row cannot hold a draft plus three controls at phone width — the
|
||||
draft gets a line of its own and the controls drop below it. */
|
||||
.structured-composer {
|
||||
margin: var(--sp-1) 0 0;
|
||||
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
border-radius: var(--r-2) var(--r-2) 0 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
padding-left: var(--sp-2);
|
||||
padding-right: var(--sp-2);
|
||||
}
|
||||
|
||||
/* `order` keeps the draft on the first line: without it the attach button,
|
||||
which precedes it in the DOM, wraps onto a line of its own. */
|
||||
.structured-input-wrap {
|
||||
flex: 1 1 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.structured-composer .structured-config {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.structured-config-menu {
|
||||
right: calc(-1 * (var(--control-h) + var(--sp-2)));
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user