Skip to content

Commit 4d77991

Browse files
committed
fix(tui): keep session facts in one contiguous stack
1 parent dca13b1 commit 4d77991

2 files changed

Lines changed: 209 additions & 74 deletions

File tree

crates/jcode-tui/src/tui/ui_input.rs

Lines changed: 180 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,10 +1070,104 @@ mod tests {
10701070
.iter()
10711071
.map(|placement| placement.area.y)
10721072
.collect::<Vec<_>>();
1073-
assert_eq!(rows, vec![3, 2, 1, 0]);
1073+
assert_eq!(rows, vec![0, 1, 2, 3]);
10741074
assert!(placements.iter().all(|placement| placement.area.y != 4));
10751075
}
10761076

1077+
#[test]
1078+
fn right_fact_stack_never_leaves_an_occupied_row_between_facts() {
1079+
let area = Rect::new(0, 0, 40, 7);
1080+
let mut buffer = ratatui::buffer::Buffer::empty(area);
1081+
buffer[(39, 4)].set_symbol("x");
1082+
let lines = ["oauth", "model", "dir", "context"]
1083+
.into_iter()
1084+
.map(|text| RightFactLine::new(vec![Span::raw(text)]).expect("fact line"))
1085+
.collect();
1086+
1087+
let placements = right_fact_placements(
1088+
&buffer,
1089+
lines,
1090+
0,
1091+
7,
1092+
0,
1093+
40,
1094+
Rect::new(0, 0, 40, 5),
1095+
false,
1096+
None,
1097+
);
1098+
let rows = placements
1099+
.iter()
1100+
.map(|placement| placement.area.y)
1101+
.collect::<Vec<_>>();
1102+
assert_eq!(rows, vec![0, 1, 2, 3]);
1103+
assert_eq!(
1104+
placements
1105+
.iter()
1106+
.map(|placement| placement.line.spans[0].content.as_ref())
1107+
.collect::<Vec<_>>(),
1108+
vec!["oauth", "model", "dir", "context"]
1109+
);
1110+
}
1111+
1112+
#[test]
1113+
fn right_fact_stack_collision_state_space_is_contiguous_or_hidden() {
1114+
const HEIGHT: u16 = 8;
1115+
const STACK_HEIGHT: u16 = 4;
1116+
1117+
for occupied_mask in 0_u16..(1 << HEIGHT) {
1118+
let area = Rect::new(0, 0, 40, HEIGHT);
1119+
let mut buffer = ratatui::buffer::Buffer::empty(area);
1120+
for row in 0..HEIGHT {
1121+
if occupied_mask & (1 << row) != 0 {
1122+
buffer[(39, row)].set_symbol("x");
1123+
}
1124+
}
1125+
let lines = ["oauth", "model", "dir", "context"]
1126+
.into_iter()
1127+
.map(|text| RightFactLine::new(vec![Span::raw(text)]).expect("fact line"))
1128+
.collect();
1129+
1130+
let placements = right_fact_placements(
1131+
&buffer,
1132+
lines,
1133+
0,
1134+
HEIGHT,
1135+
0,
1136+
40,
1137+
Rect::new(0, 0, 40, HEIGHT),
1138+
false,
1139+
None,
1140+
);
1141+
let expected_top = (0..=HEIGHT - STACK_HEIGHT).rev().find(|&start| {
1142+
(start..start + STACK_HEIGHT)
1143+
.all(|row| occupied_mask & (1 << row) == 0)
1144+
});
1145+
1146+
match expected_top {
1147+
Some(start) => {
1148+
assert_eq!(placements.len(), STACK_HEIGHT as usize, "mask {occupied_mask:08b}");
1149+
assert_eq!(
1150+
placements
1151+
.iter()
1152+
.map(|placement| placement.area.y)
1153+
.collect::<Vec<_>>(),
1154+
(start..start + STACK_HEIGHT).collect::<Vec<_>>(),
1155+
"mask {occupied_mask:08b}"
1156+
);
1157+
assert_eq!(
1158+
placements
1159+
.iter()
1160+
.map(|placement| placement.line.spans[0].content.as_ref())
1161+
.collect::<Vec<_>>(),
1162+
vec!["oauth", "model", "dir", "context"],
1163+
"mask {occupied_mask:08b}"
1164+
);
1165+
}
1166+
None => assert!(placements.is_empty(), "mask {occupied_mask:08b}"),
1167+
}
1168+
}
1169+
}
1170+
10771171
#[test]
10781172
fn right_fact_stack_treats_styled_blank_cells_as_occupied() {
10791173
let area = Rect::new(0, 0, 32, 2);
@@ -2200,10 +2294,7 @@ fn right_fact_lines(app: &dyn TuiState) -> Vec<RightFactLine> {
22002294
if let Some(dir) = app
22012295
.working_dir()
22022296
.and_then(|path| overscroll_dir_label(&path))
2203-
&& let Some(line) = RightFactLine::new(vec![Span::styled(
2204-
dir,
2205-
right_fact_neutral_style(),
2206-
)])
2297+
&& let Some(line) = RightFactLine::new(vec![Span::styled(dir, right_fact_neutral_style())])
22072298
{
22082299
lines.push(line);
22092300
}
@@ -2241,64 +2332,101 @@ fn right_fact_placements(
22412332
transcript_scrollbar_visible: bool,
22422333
protected_position: Option<Position>,
22432334
) -> Vec<RightFactPlacement> {
2244-
if top >= bottom || left >= right {
2335+
if top >= bottom || left >= right || lines.is_empty() {
22452336
return Vec::new();
22462337
}
22472338

2248-
let mut placements = Vec::with_capacity(lines.len());
2249-
let mut next_row = bottom.checked_sub(1);
2339+
let Ok(block_height) = u16::try_from(lines.len()) else {
2340+
return Vec::new();
2341+
};
2342+
if bottom.saturating_sub(top) < block_height {
2343+
return Vec::new();
2344+
}
22502345

2251-
for line in lines.into_iter().rev() {
2252-
let Some(mut row) = next_row else {
2253-
break;
2346+
// Facts are one visual object. Probe complete consecutive blocks from the
2347+
// bottom upward; if any row collides, move the entire stack rather than
2348+
// skipping that row and letting unrelated content split the facts apart.
2349+
let mut block_bottom = bottom;
2350+
loop {
2351+
let Some(block_top) = block_bottom.checked_sub(block_height) else {
2352+
return Vec::new();
22542353
};
2255-
let mut placed = None;
2256-
2257-
loop {
2258-
if row < top {
2259-
break;
2260-
}
2261-
let row_right = if transcript_scrollbar_visible
2262-
&& row >= messages_area.y
2263-
&& row < messages_area.bottom()
2264-
{
2265-
right.saturating_sub(1)
2266-
} else {
2267-
right
2268-
};
2269-
2270-
let required = line
2271-
.width
2272-
.saturating_add(RIGHT_FACT_GAP)
2273-
.saturating_add(RIGHT_FACT_PAD);
2274-
if row_right.saturating_sub(left) >= required {
2275-
let fact_right = row_right.saturating_sub(RIGHT_FACT_PAD);
2276-
let fact_left = fact_right.saturating_sub(line.width);
2277-
let probe_left = fact_left.saturating_sub(RIGHT_FACT_GAP);
2278-
if probe_left >= left
2279-
&& (probe_left..row_right).all(|x| {
2280-
protected_position != Some(Position::new(x, row))
2281-
&& right_fact_cell_is_blank(&buffer[(x, row)])
2282-
})
2283-
{
2284-
placed = Some(Rect::new(fact_left, row, line.width, 1));
2285-
break;
2286-
}
2287-
}
2354+
if block_top < top {
2355+
return Vec::new();
2356+
}
22882357

2289-
let Some(previous) = row.checked_sub(1) else {
2290-
break;
2291-
};
2292-
row = previous;
2358+
let areas = lines
2359+
.iter()
2360+
.enumerate()
2361+
.map(|(index, line)| {
2362+
let row = block_top + index as u16;
2363+
right_fact_area_on_row(
2364+
buffer,
2365+
line,
2366+
row,
2367+
left,
2368+
right,
2369+
messages_area,
2370+
transcript_scrollbar_visible,
2371+
protected_position,
2372+
)
2373+
})
2374+
.collect::<Option<Vec<_>>>();
2375+
if let Some(areas) = areas {
2376+
return lines
2377+
.into_iter()
2378+
.zip(areas)
2379+
.map(|(line, area)| RightFactPlacement { line, area })
2380+
.collect();
22932381
}
22942382

2295-
if let Some(area) = placed {
2296-
next_row = area.y.checked_sub(1);
2297-
placements.push(RightFactPlacement { line, area });
2383+
if block_top == top {
2384+
return Vec::new();
22982385
}
2386+
block_bottom = block_bottom.saturating_sub(1);
2387+
}
2388+
}
2389+
2390+
fn right_fact_area_on_row(
2391+
buffer: &ratatui::buffer::Buffer,
2392+
line: &RightFactLine,
2393+
row: u16,
2394+
left: u16,
2395+
right: u16,
2396+
messages_area: Rect,
2397+
transcript_scrollbar_visible: bool,
2398+
protected_position: Option<Position>,
2399+
) -> Option<Rect> {
2400+
let row_right = if transcript_scrollbar_visible
2401+
&& row >= messages_area.y
2402+
&& row < messages_area.bottom()
2403+
{
2404+
right.saturating_sub(1)
2405+
} else {
2406+
right
2407+
};
2408+
2409+
let required = line
2410+
.width
2411+
.saturating_add(RIGHT_FACT_GAP)
2412+
.saturating_add(RIGHT_FACT_PAD);
2413+
if row_right.saturating_sub(left) < required {
2414+
return None;
2415+
}
2416+
2417+
let fact_right = row_right.saturating_sub(RIGHT_FACT_PAD);
2418+
let fact_left = fact_right.saturating_sub(line.width);
2419+
let probe_left = fact_left.saturating_sub(RIGHT_FACT_GAP);
2420+
if probe_left < left
2421+
|| !(probe_left..row_right).all(|x| {
2422+
protected_position != Some(Position::new(x, row))
2423+
&& right_fact_cell_is_blank(&buffer[(x, row)])
2424+
})
2425+
{
2426+
return None;
22992427
}
23002428

2301-
placements
2429+
Some(Rect::new(fact_left, row, line.width, 1))
23022430
}
23032431

23042432
fn right_fact_cell_is_blank(cell: &ratatui::buffer::Cell) -> bool {

crates/jcode-tui/src/tui/ui_tests/swarm_buffer.rs

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,25 @@ fn row_containing(rows: &[String], needle: &str) -> usize {
9292
.unwrap_or_else(|| panic!("missing {needle:?} in frame:\n{}", rows.join("\n")))
9393
}
9494

95+
fn fact_stack_rows(rows: &[String]) -> [usize; 4] {
96+
[
97+
row_containing(rows, "OpenAI · OAuth"),
98+
row_containing(rows, "GPT-5.6-sol high"),
99+
row_containing(rows, "~/jcode"),
100+
row_containing(rows, "74k/256k"),
101+
]
102+
}
103+
104+
fn assert_fact_stack_is_contiguous(rows: &[String]) -> [usize; 4] {
105+
let positions = fact_stack_rows(rows);
106+
assert!(
107+
positions.windows(2).all(|pair| pair[1] == pair[0] + 1),
108+
"facts must be one uninterrupted OAuth/model/directory/context block:\n{}",
109+
rows.join("\n")
110+
);
111+
positions
112+
}
113+
95114
#[test]
96115
fn right_fact_stack_uses_transcript_status_notification_and_input_rows_in_order() {
97116
let _lock = viewport_snapshot_test_lock();
@@ -104,10 +123,7 @@ fn right_fact_stack_uses_transcript_status_notification_and_input_rows_in_order(
104123
.expect("fact stack frame");
105124

106125
let rows = buffer_rows(&terminal);
107-
let oauth_y = row_containing(&rows, "OpenAI · OAuth");
108-
let model_y = row_containing(&rows, "GPT-5.6-sol high");
109-
let dir_y = row_containing(&rows, "~/jcode");
110-
let context_y = row_containing(&rows, "74k/256k");
126+
let [oauth_y, model_y, dir_y, context_y] = assert_fact_stack_is_contiguous(&rows);
111127
assert!(oauth_y < model_y && model_y < dir_y && dir_y < context_y);
112128
assert!(rows[context_y].contains("▰▰▱▱▱▱ 29%"));
113129
assert!(rows[dir_y].contains("next scheduled task in 4m"));
@@ -170,10 +186,7 @@ fn right_fact_stack_shifts_up_when_scheduled_notification_row_is_absent() {
170186
.expect("fact stack frame without notification");
171187

172188
let rows = buffer_rows(&terminal);
173-
let oauth_y = row_containing(&rows, "OpenAI · OAuth");
174-
let model_y = row_containing(&rows, "GPT-5.6-sol high");
175-
let dir_y = row_containing(&rows, "~/jcode");
176-
let context_y = row_containing(&rows, "74k/256k");
189+
let [oauth_y, model_y, dir_y, context_y] = assert_fact_stack_is_contiguous(&rows);
177190
let layout = crate::tui::ui::last_layout_snapshot().expect("layout snapshot");
178191
let input = layout.input_area.expect("input area");
179192
let status = crate::tui::ui::last_status_area().expect("status area");
@@ -212,6 +225,7 @@ fn right_fact_stack_leaves_fully_used_input_rows_untouched_and_moves_up() {
212225
>= 110
213226
);
214227
assert!(row_containing(&rows, "74k/256k") < input_area.y as usize);
228+
assert_fact_stack_is_contiguous(&rows);
215229
}
216230

217231
#[test]
@@ -231,7 +245,7 @@ fn right_fact_stack_survives_narrow_widths_without_overwriting_content() {
231245
}
232246

233247
#[test]
234-
fn right_fact_stack_does_not_spill_into_a_live_streaming_transcript() {
248+
fn right_fact_stack_hides_as_a_unit_when_streaming_chrome_cannot_fit_it() {
235249
let _lock = viewport_snapshot_test_lock();
236250
clear_flicker_frame_history_for_tests();
237251
let mut state = fact_test_state(String::new(), true);
@@ -244,22 +258,15 @@ fn right_fact_stack_does_not_spill_into_a_live_streaming_transcript() {
244258
.expect("streaming fact stack frame");
245259

246260
let rows = buffer_rows(&terminal);
247-
let messages_bottom = crate::tui::ui::last_layout_snapshot()
248-
.expect("layout snapshot")
249-
.messages_area
250-
.bottom() as usize;
251-
let dir_y = row_containing(&rows, "~/jcode");
252-
let context_y = row_containing(&rows, "74k/256k");
253261
assert!(
254-
dir_y >= messages_bottom && context_y >= messages_bottom,
255-
"streaming facts must not composite into live transcript rows:\n{}",
262+
rows.iter().all(|row| {
263+
!row.contains("OpenAI · OAuth")
264+
&& !row.contains("GPT-5.6-sol high")
265+
&& !row.contains("74k/256k")
266+
}),
267+
"the stack must hide completely rather than render a partial block:\n{}",
256268
rows.join("\n")
257269
);
258-
assert!(rows[..messages_bottom].iter().all(|row| {
259-
!row.contains("OpenAI · OAuth")
260-
&& !row.contains("GPT-5.6-sol high")
261-
&& !row.contains("74k/256k")
262-
}));
263270
}
264271

265272
#[test]

0 commit comments

Comments
 (0)