Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/localization/locales/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "No s'ha pogut reordenar la fixació: {error}",
"ModelPickerReadinessRefreshed": "S'ha actualitzat la disponibilitat dels models · files del catàleg reconstruïdes",
"ModelPickerOpenToRefresh": "Obre /model per actualitzar la disponibilitat i el catàleg",
"ModelPickerPinnedChip": "fixat"
"ModelPickerPinnedChip": "fixat",
"PinnedPromptJumpToMessage": "Ves a aquest missatge"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "Anheftung konnte nicht verschoben werden: {error}",
"ModelPickerReadinessRefreshed": "Modellbereitschaft aktualisiert · Katalogzeilen neu aufgebaut",
"ModelPickerOpenToRefresh": "Öffne /model, um Bereitschaft und Katalog zu aktualisieren",
"ModelPickerPinnedChip": "angeheftet"
"ModelPickerPinnedChip": "angeheftet",
"PinnedPromptJumpToMessage": "Zu dieser Nachricht springen"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "Could not reorder pin: {error}",
"ModelPickerReadinessRefreshed": "Model readiness refreshed · catalog rows rebuilt",
"ModelPickerOpenToRefresh": "Open /model to refresh readiness and catalog",
"ModelPickerPinnedChip": "pinned"
"ModelPickerPinnedChip": "pinned",
"PinnedPromptJumpToMessage": "Jump to this message"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "No se pudo reordenar la fijación: {error}",
"ModelPickerReadinessRefreshed": "Disponibilidad de modelos actualizada · filas del catálogo reconstruidas",
"ModelPickerOpenToRefresh": "Abre /model para actualizar la disponibilidad y el catálogo",
"ModelPickerPinnedChip": "fijado"
"ModelPickerPinnedChip": "fijado",
"PinnedPromptJumpToMessage": "Ir a este mensaje"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "Impossible de réordonner l’épingle : {error}",
"ModelPickerReadinessRefreshed": "Disponibilité des modèles actualisée · lignes du catalogue reconstruites",
"ModelPickerOpenToRefresh": "Ouvrez /model pour actualiser la disponibilité et le catalogue",
"ModelPickerPinnedChip": "épinglé"
"ModelPickerPinnedChip": "épinglé",
"PinnedPromptJumpToMessage": "Aller à ce message"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "पिन का क्रम नहीं बदला जा सका: {error}",
"ModelPickerReadinessRefreshed": "मॉडल की तैयारी रीफ़्रेश हुई · कैटलॉग पंक्तियाँ फिर से बनीं",
"ModelPickerOpenToRefresh": "तैयारी और कैटलॉग रीफ़्रेश करने के लिए /model खोलें",
"ModelPickerPinnedChip": "पिन किया"
"ModelPickerPinnedChip": "पिन किया",
"PinnedPromptJumpToMessage": "इस संदेश पर जाएँ"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "Tidak dapat mengubah urutan sematan: {error}",
"ModelPickerReadinessRefreshed": "Kesiapan model diperbarui · baris katalog dibangun ulang",
"ModelPickerOpenToRefresh": "Buka /model untuk memperbarui kesiapan dan katalog",
"ModelPickerPinnedChip": "disematkan"
"ModelPickerPinnedChip": "disematkan",
"PinnedPromptJumpToMessage": "Lompat ke pesan ini"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "ピン留めの順序を変更できませんでした: {error}",
"ModelPickerReadinessRefreshed": "モデルの準備状況を更新しました · カタログ行を再構築しました",
"ModelPickerOpenToRefresh": "準備状況とカタログを更新するには /model を開いてください",
"ModelPickerPinnedChip": "ピン留め"
"ModelPickerPinnedChip": "ピン留め",
"PinnedPromptJumpToMessage": "このメッセージへジャンプ"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "고정 순서를 바꾸지 못했습니다: {error}",
"ModelPickerReadinessRefreshed": "모델 준비 상태를 새로 고쳤습니다 · 카탈로그 행을 다시 만들었습니다",
"ModelPickerOpenToRefresh": "준비 상태와 카탈로그를 새로 고치려면 /model을 여세요",
"ModelPickerPinnedChip": "고정됨"
"ModelPickerPinnedChip": "고정됨",
"PinnedPromptJumpToMessage": "이 메시지로 이동"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "Não foi possível reordenar a fixação: {error}",
"ModelPickerReadinessRefreshed": "Prontidão dos modelos atualizada · linhas do catálogo reconstruídas",
"ModelPickerOpenToRefresh": "Abra /model para atualizar a prontidão e o catálogo",
"ModelPickerPinnedChip": "fixado"
"ModelPickerPinnedChip": "fixado",
"PinnedPromptJumpToMessage": "Ir para esta mensagem"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "Не удалось изменить порядок закрепления: {error}",
"ModelPickerReadinessRefreshed": "Готовность моделей обновлена · строки каталога перестроены",
"ModelPickerOpenToRefresh": "Откройте /model, чтобы обновить готовность и каталог",
"ModelPickerPinnedChip": "закреплено"
"ModelPickerPinnedChip": "закреплено",
"PinnedPromptJumpToMessage": "Перейти к этому сообщению"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "Не вдалося змінити порядок закріплення: {error}",
"ModelPickerReadinessRefreshed": "Готовність моделей оновлено · рядки каталогу перебудовано",
"ModelPickerOpenToRefresh": "Відкрийте /model, щоб оновити готовність і каталог",
"ModelPickerPinnedChip": "закріплено"
"ModelPickerPinnedChip": "закріплено",
"PinnedPromptJumpToMessage": "Перейти до цього повідомлення"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "Không thể sắp xếp lại ghim: {error}",
"ModelPickerReadinessRefreshed": "Đã làm mới trạng thái sẵn sàng của mô hình · đã dựng lại các dòng danh mục",
"ModelPickerOpenToRefresh": "Mở /model để làm mới trạng thái sẵn sàng và danh mục",
"ModelPickerPinnedChip": "đã ghim"
"ModelPickerPinnedChip": "đã ghim",
"PinnedPromptJumpToMessage": "Nhảy đến tin nhắn này"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerPinReorderFailed": "无法调整固定顺序:{error}",
"ModelPickerReadinessRefreshed": "已刷新模型就绪状态 · 已重建目录行",
"ModelPickerOpenToRefresh": "打开 /model 以刷新就绪状态和目录",
"ModelPickerPinnedChip": "已固定"
"ModelPickerPinnedChip": "已固定",
"PinnedPromptJumpToMessage": "跳转到这条消息"
}
3 changes: 2 additions & 1 deletion crates/localization/locales/zh-Hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"ModelPickerOpenToRefresh": "開啟 /model 以重新整理就緒狀態和目錄",
"ModelPickerPinnedChip": "已釘選",
"TranscriptThought": "思考完畢",
"TranscriptThoughtFor": "思考耗時 {duration}"
"TranscriptThoughtFor": "思考耗時 {duration}",
"PinnedPromptJumpToMessage": "跳轉到這則訊息"
}
4 changes: 4 additions & 0 deletions crates/localization/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2673,6 +2673,9 @@ pub enum MessageId {
ModelPickerReadinessRefreshed,
ModelPickerOpenToRefresh,
ModelPickerPinnedChip,
/// Hover label for the pinned user-prompt header above the transcript:
/// clicking the header jumps to the user message it names.
PinnedPromptJumpToMessage,
}

#[allow(dead_code)]
Expand Down Expand Up @@ -5118,6 +5121,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::ModelPickerReadinessRefreshed,
MessageId::ModelPickerOpenToRefresh,
MessageId::ModelPickerPinnedChip,
MessageId::PinnedPromptJumpToMessage,
];

pub fn tr(locale: Locale, id: MessageId) -> Cow<'static, str> {
Expand Down
59 changes: 59 additions & 0 deletions crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,16 @@ pub struct ViewportState {
pub last_transcript_total: usize,
pub last_transcript_padding_top: usize,
pub jump_to_latest_button_area: Option<Rect>,
/// Painted rect of the pinned user-prompt header above the transcript,
/// when one is shown and mouse capture is on. A left click there jumps
/// the viewport to the message named by `pinned_prompt_message`.
pub pinned_prompt_area: Option<Rect>,
/// Original history index of the user message the pinned header
/// describes; the click target for `pinned_prompt_area`. Stored as a
/// message identity, not a line offset, because line offsets are
/// frame-bound and a rewrite between paint and click would otherwise
/// land the jump on whatever now sits at the stale offset.
pub pinned_prompt_message: Option<usize>,
/// Inner content rect of the composer (excluding border/padding),
/// stored at render time for mouse coordinate mapping.
pub last_composer_content: Option<Rect>,
Expand Down Expand Up @@ -1061,6 +1071,8 @@ impl Default for ViewportState {
last_transcript_total: 0,
last_transcript_padding_top: 0,
jump_to_latest_button_area: None,
pinned_prompt_area: None,
pinned_prompt_message: None,
last_composer_content: None,
last_composer_scroll_offset: 0,
last_composer_top_padding: 0,
Expand Down Expand Up @@ -6030,6 +6042,8 @@ impl App {
self.viewport.last_transcript_total = 0;
self.viewport.last_transcript_padding_top = 0;
self.viewport.jump_to_latest_button_area = None;
self.viewport.pinned_prompt_area = None;
self.viewport.pinned_prompt_message = None;

self.needs_redraw = true;
}
Expand Down Expand Up @@ -6066,6 +6080,51 @@ impl App {
self.needs_redraw = true;
}

/// Jump the transcript viewport so rendered line `line` becomes its top
/// row. The pinned prompt header calls this to return to the user message
/// it names. Mirrors the wheel/scrollbar path: pending wheel deltas are
/// dropped so the jump lands where it was asked to, and the viewport
/// leaves the live tail.
pub fn scroll_to_transcript_line(&mut self, line: usize) {
self.viewport.transcript_scroll = TranscriptScroll::at_line(line);
self.viewport.pending_scroll_delta = 0;
// `at_line` is never the tail sentinel, so this reads as `true` today;
// keep the same expression the scrollbar-jump path uses so the two
// stay in step if `at_line` ever clamps to tail on its own.
self.user_scrolled_during_stream = !self.viewport.transcript_scroll.is_at_tail();
self.needs_redraw = true;
}

/// First rendered line of the user message named by the pinned prompt
/// header, resolved against the current transcript layout.
///
/// The header records the message, not a line offset, and this resolves
/// that identity at click time — a rewrite between paint and click then
/// cannot land the jump on a stale offset. Returns `None` when the
/// message is no longer rendered (collapsed or filtered out), so a stale
/// click cannot teleport the viewport.
pub fn pinned_prompt_target_line(&self) -> Option<usize> {
let message = self.viewport.pinned_prompt_message?;
let map = &self.collapsed_cell_map;
self.viewport
.transcript_cache
.line_meta()
.iter()
.enumerate()
.find_map(|(line_index, meta)| {
let TranscriptLineMeta::CellLine {
cell_index,
line_in_cell: 0,
..
} = meta
else {
return None;
};
let original = map.get(*cell_index).copied().unwrap_or(*cell_index);
(original == message).then_some(line_index)
})
}

pub fn queue_message(&mut self, message: QueuedMessage) {
self.queued_messages.push_back(message);
}
Expand Down
11 changes: 11 additions & 0 deletions crates/tui/src/tui/mouse_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,17 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return Vec::new();
}

// The pinned prompt header names the user message scrolled just
// above the viewport; a click returns to it. Resolve the message
// against the current layout so a rewrite between paint and click
// cannot jump to a stale line offset.
if mouse_hits_rect(mouse, app.viewport.pinned_prompt_area) {
if let Some(line) = app.pinned_prompt_target_line() {
app.scroll_to_transcript_line(line);
}
return Vec::new();
}

if toggle_tool_run_expand(app, mouse) {
return Vec::new();
}
Expand Down
35 changes: 34 additions & 1 deletion crates/tui/src/tui/ui/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,11 +565,16 @@ fn render_workbar(f: &mut Frame, app: &App, area: Rect) {
/// through every widget that happens to remember.
fn register_clickable_chrome_for_hover(app: &App) {
use codewhale_localization::MessageId;
let targets: [(Option<Rect>, MessageId); 4] = [
let targets: [(Option<Rect>, MessageId); 5] = [
(
app.viewport.jump_to_latest_button_area,
MessageId::KbJumpTopBottom,
),
(
// The pinned prompt header jumps to the user message it names.
app.viewport.pinned_prompt_area,
MessageId::PinnedPromptJumpToMessage,
),
(
app.viewport.last_plugin_cta_review_area,
MessageId::PluginCtaReview,
Expand Down Expand Up @@ -1740,6 +1745,13 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
shell_ocean = chat_widget.ocean_column();
}
app.viewport.pending_scroll_delta = parked_scroll_delta;
// The sampling constructor above records the pinned prompt header's
// hit box from the main session's transcript, but the focus pane
// never paints that header — its first row is the agent banner.
// Drop the stale box so the banner cannot answer a click meant for
// the (hidden) main transcript.
app.viewport.pinned_prompt_area = None;
app.viewport.pinned_prompt_message = None;
crate::tui::agent_focus::refresh_focus(app);
let buf = f.buffer_mut();
crate::tui::agent_focus::render_focus(app, chat_area, buf);
Expand Down Expand Up @@ -2300,6 +2312,27 @@ mod tests {
);
}

/// The pinned prompt header answers a click in `mouse_ui`; it must light
/// up under the pointer like every other clickable chrome.
#[test]
fn pinned_prompt_header_registers_a_hover_target() {
let _guard = crate::tui::hover_layer::HOVER_TEST_LOCK.lock().unwrap();
crate::tui::hover_layer::begin_frame();
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
let header = ratatui::layout::Rect::new(4, 3, 40, 1);
app.viewport.pinned_prompt_area = Some(header);

super::register_clickable_chrome_for_hover(&app);

let registered = crate::tui::hover_layer::registered_targets();
assert!(
registered.iter().any(|hit| hit.area == header),
"the pinned prompt header handles a click in mouse_ui and must \
light up under the pointer; registered: {registered:?}"
);
}

/// The composer's `[↵]` answered clicks and showed nothing under the
/// pointer — the last of the clickable-but-dark controls. It lights up
/// only when a click there would actually send.
Expand Down
75 changes: 75 additions & 0 deletions crates/tui/src/tui/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4884,6 +4884,81 @@ fn jump_to_latest_button_click_scrolls_to_tail() {
assert!(!app.viewport.transcript_selection.dragging);
}

/// Clicking the pinned prompt header returns the viewport to the user
/// message the header names, resolved against the current layout.
#[test]
fn pinned_prompt_click_jumps_to_the_message_it_names() {
let mut app = create_test_app();
app.history = vec![HistoryCell::User {
content: "jump target".to_string(),
}];
app.resync_history_revisions();
app.viewport.transcript_cache.ensure(
&app.history,
&app.history_revisions,
80,
app.transcript_render_options(),
);
app.collapsed_cell_map = vec![0];
app.viewport.transcript_scroll = TranscriptScroll::at_line(5);
app.viewport.pinned_prompt_area = Some(Rect {
x: 4,
y: 3,
width: 40,
height: 1,
});
app.viewport.pinned_prompt_message = Some(0);

let events = handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 8,
row: 3,
modifiers: KeyModifiers::NONE,
},
);

assert!(events.is_empty());
assert_eq!(
app.viewport.transcript_scroll,
TranscriptScroll::at_line(0),
"the click must pin the viewport to the resolved message's first line"
);
assert_eq!(app.viewport.pending_scroll_delta, 0);
assert!(app.user_scrolled_during_stream);
assert!(!app.viewport.transcript_selection.dragging);
}

/// A click outside the header's row must not be captured by its hit box.
#[test]
fn pinned_prompt_click_only_claims_the_header_row() {
let mut app = create_test_app();
app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
app.viewport.pinned_prompt_area = Some(Rect {
x: 4,
y: 3,
width: 40,
height: 1,
});
app.viewport.pinned_prompt_message = Some(12);

handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 8,
row: 4,
modifiers: KeyModifiers::NONE,
},
);

assert!(
app.viewport.transcript_scroll.is_at_tail(),
"a click below the header must not trigger the jump"
);
}

/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not
/// text selection) so the visible thumb remains interactive for users who
/// prefer mouse-based navigation.
Expand Down
Loading
Loading