diff --git a/bridge/src/prompt64/server.py b/bridge/src/prompt64/server.py index bf8beee..a8988d2 100644 --- a/bridge/src/prompt64/server.py +++ b/bridge/src/prompt64/server.py @@ -328,17 +328,30 @@ class BridgeSession: if not await self.emit(message_type, chunk): break if not self.overflowed: - await self.emit( + terminal = ( MessageType.TURN_CANCELLED if self.cancel_requested else MessageType.TURN_DONE ) + await self.emit(terminal) + LOGGER.info( + "Queued %s for turn sequence %d", + terminal.name, + self.sequence, + ) except ProviderError as error: if not self.overflowed: await self.emit( MessageType.TURN_FAILED, c64_text(str(error))[:90] or b"PROVIDER ERROR", ) + except Exception: + LOGGER.exception("Unexpected bridge turn failure") + if not self.overflowed: + await self.emit( + MessageType.TURN_FAILED, + b"UNEXPECTED BRIDGE TURN FAILURE", + ) finally: self.cancel_requested = False @@ -772,9 +785,9 @@ async def send_dashboard(session: BridgeSession, tab: int) -> None: try: snapshot = await asyncio.to_thread(git_snapshot, session.workspace.path) session.change_snapshot = snapshot - except DashboardError as error: - await session.send(Frame(MessageType.ERROR, c64_text(str(error))[:92])) - return + except DashboardError: + session.change_snapshot = None + snapshot = GitSnapshot("UNAVAILABLE", ()) rows = [] for entry in snapshot.entries: counts = ( diff --git a/bridge/tests/test_protocol.py b/bridge/tests/test_protocol.py index d96e0af..7e8edc6 100644 --- a/bridge/tests/test_protocol.py +++ b/bridge/tests/test_protocol.py @@ -149,6 +149,16 @@ class LongActivityProvider(RecordingProvider): yield ActivityUpdate("COMMAND", "X" * 200, ActivityState.COMPLETED) +class UnexpectedFailureProvider(RecordingProvider): + async def stream( + self, + prompt: bytes, + reasoning: ReasoningMode, + ) -> AsyncIterator[TextDelta | ApprovalRequest]: + yield TextDelta(TextKind.ASSISTANT, "BEFORE FAILURE") + raise RuntimeError("synthetic unexpected failure") + + class SessionProvider(RecordingProvider): def __init__(self, resumed: Path) -> None: super().__init__() @@ -285,6 +295,22 @@ class BridgeTests(unittest.IsolatedAsyncioTestCase): ) self.assertIs((await read_frame(reader)).kind, MessageType.TURN_DONE) + async def test_unexpected_provider_failure_terminates_turn(self) -> None: + server = await start_server("127.0.0.1", 0, UnexpectedFailureProvider()) + self.addAsyncCleanup(self._close_server, server) + reader, writer, _ = await self._connect(server) + self.addAsyncCleanup(self._close_writer, writer) + await write_frame(writer, Frame(MessageType.USER_TEXT, b"TEST")) + + first = await asyncio.wait_for(read_frame(reader), 1) + self.assertIs(first.kind, MessageType.ASSISTANT_DELTA) + failure = await asyncio.wait_for(read_frame(reader), 1) + self.assertIs(failure.kind, MessageType.TURN_FAILED) + self.assertEqual( + decode_sequenced_payload(failure.payload)[1], + b"UNEXPECTED BRIDGE TURN FAILURE", + ) + async def test_dashboard_traffic_interleaves_with_active_turn(self) -> None: provider = PausableProvider() server = await start_server("127.0.0.1", 0, provider) @@ -328,6 +354,38 @@ class BridgeTests(unittest.IsolatedAsyncioTestCase): ), ) + async def test_non_git_changes_tab_is_empty_instead_of_failing(self) -> None: + with tempfile.TemporaryDirectory() as root: + server = await start_server( + "127.0.0.1", + 0, + FakeProvider(), + workspace_root=root, + default_workspace=root, + ) + self.addAsyncCleanup(self._close_server, server) + reader, writer, _ = await self._connect(server) + self.addAsyncCleanup(self._close_writer, writer) + + await write_frame(writer, Frame(MessageType.DASHBOARD_GET, b"\x00")) + status_rows = [] + while True: + frame = await read_frame(reader) + if frame.kind is MessageType.DASHBOARD_DONE: + break + status_rows.append(frame.payload[2:]) + self.assertIn(b"GIT UNAVAILABLE", status_rows) + + await write_frame(writer, Frame(MessageType.DASHBOARD_GET, b"\x03")) + while (await read_frame(reader)).kind is not MessageType.DASHBOARD_DONE: + pass + + await write_frame(writer, Frame(MessageType.DASHBOARD_GET, b"\x02")) + self.assertEqual( + await read_frame(reader), + Frame(MessageType.DASHBOARD_DONE, b"\x02\x00"), + ) + async def test_settings_and_history_use_distinct_frames(self) -> None: with tempfile.TemporaryDirectory() as root: default = Path(root) / "new" diff --git a/client/src/chrome.rs b/client/src/chrome.rs index 302eed5..b33d03e 100644 --- a/client/src/chrome.rs +++ b/client/src/chrome.rs @@ -14,8 +14,8 @@ const BOTTOM_PAGE_B: u8 = 48; pub const CONTROLS_CONNECTION: &[u8] = b"UP/DN FIELD L/R EDIT RET GO STOP"; pub const CONTROLS_SESSION: &[u8] = b"UP/DN SELECT RET OPEN STOP"; pub const CONTROLS_PROMPT: &[u8] = b"F1 SET F3 LOG F5 SESS F7 DASH STOP"; -pub const CONTROLS_TURN: &[u8] = b"F7 DASH STOP CANCEL"; -pub const CONTROLS_DASHBOARD: &[u8] = b"F1/F3 TAB UP/DN L/R PAGE RET F7"; +pub const CONTROLS_TURN: &[u8] = b"F3 LOG F7 DASH STOP CANCEL"; +pub const CONTROLS_DASHBOARD: &[u8] = b"F1/F3 CYCLE UP/DN L/R PAGE RET F7"; pub const CONTROLS_SETTINGS: &[u8] = b"L/R CHANGE RET SET STOP"; pub const CONTROLS_APPROVAL: &[u8] = b"RET ONCE F1 SESSION D NO STOP"; pub const CONTROLS_SCROLL: &[u8] = b"UP/DN L/R PAGE CLR F3 RETURN"; @@ -127,6 +127,7 @@ const fn glyph(byte: u8) -> [u8; 6] { b'S' => [0x6, 0x8, 0x4, 0x2, 0xc, 0], b'T' => [0xe, 0x4, 0x4, 0x4, 0x4, 0], b'U' => [0xa, 0xa, 0xa, 0xa, 0x4, 0], + b'Y' => [0xa, 0xa, 0x4, 0x4, 0x4, 0], b'/' => [0x2, 0x2, 0x4, 0x8, 0x8, 0], _ => [0; 6], } @@ -173,5 +174,20 @@ mod tests { fn renders_function_key_digits() { assert_eq!(glyph(b'5'), [0xe, 0x8, 0xc, 0x2, 0xc, 0]); assert_eq!(glyph(b'7'), [0xe, 0x2, 0x2, 0x4, 0x4, 0]); + assert_eq!(glyph(b'Y'), [0xa, 0xa, 0x4, 0x4, 0x4, 0]); + } + + #[test] + fn active_turn_controls_keep_scrollback_available() { + assert!(super::CONTROLS_TURN + .windows(b"F3 LOG".len()) + .any(|window| window == b"F3 LOG")); + } + + #[test] + fn dashboard_controls_explain_tab_navigation() { + assert!(super::CONTROLS_DASHBOARD + .windows(b"F1/F3 CYCLE".len()) + .any(|window| window == b"F1/F3 CYCLE")); } } diff --git a/client/src/dashboard.rs b/client/src/dashboard.rs index 670a7b9..4e9277d 100644 --- a/client/src/dashboard.rs +++ b/client/src/dashboard.rs @@ -1,6 +1,7 @@ pub const DASHBOARD_ROWS: usize = 96; pub const ROW_BYTES: usize = 40; pub const PAGE_ROWS: usize = 18; +pub const TAB_BAR: &[u8] = b"STATUS ACTIVITY CHANGES ACTIONS"; #[derive(Clone, Copy, PartialEq, Debug)] #[repr(u8)] @@ -12,12 +13,12 @@ pub enum DashboardTab { } impl DashboardTab { - pub const fn label(self) -> &'static [u8] { + pub const fn span(self) -> (usize, usize) { match self { - Self::Status => b"STATUS", - Self::Activity => b"ACTIVITY", - Self::Changes => b"CHANGES", - Self::Actions => b"ACTIONS", + Self::Status => (0, 6), + Self::Activity => (7, 15), + Self::Changes => (16, 23), + Self::Actions => (24, 31), } } @@ -187,7 +188,20 @@ pub fn copy_action(prompt: &[u8], output: &mut [u8]) -> usize { #[cfg(test)] mod tests { - use super::{copy_action, Dashboard, DashboardTab, PAGE_ROWS}; + use super::{copy_action, Dashboard, DashboardTab, PAGE_ROWS, TAB_BAR}; + + #[test] + fn tab_spans_select_each_full_label() { + for (tab, label) in [ + (DashboardTab::Status, b"STATUS".as_slice()), + (DashboardTab::Activity, b"ACTIVITY"), + (DashboardTab::Changes, b"CHANGES"), + (DashboardTab::Actions, b"ACTIONS"), + ] { + let (start, end) = tab.span(); + assert_eq!(&TAB_BAR[start..end], label); + } + } #[test] fn navigates_tabs_rows_and_pages() { @@ -202,6 +216,10 @@ mod tests { assert!(dashboard.older_page()); dashboard.previous_tab(false); assert_eq!(dashboard.tab, DashboardTab::Status); + dashboard.previous_tab(false); + assert_eq!(dashboard.tab, DashboardTab::Actions); + dashboard.previous_tab(false); + assert_eq!(dashboard.tab, DashboardTab::Changes); } #[test] diff --git a/client/src/main.rs b/client/src/main.rs index c6c4eb2..5eca6d5 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -789,6 +789,11 @@ fn receive_turn( let mut dashboard_visible = false; loop { let key = poll_key!(); + if key == KEY_F3 && !dashboard_visible { + browse_scrollback(screen); + screen.render_input(&[], 0); + chrome::set_controls(chrome::CONTROLS_TURN); + } if key == KEY_F7 { dashboard_visible = !dashboard_visible; if dashboard_visible { @@ -1132,7 +1137,11 @@ fn read_prompt( return Ok(InputResult::Send(editor.length())); } KEY_F1 => configure_settings(screen, editor, socket, inbound, received, reasoning)?, - KEY_F3 => browse_scrollback(screen, editor), + KEY_F3 => { + browse_scrollback(screen); + screen.restore_composer(editor.as_slice(), editor.cursor()); + chrome::set_controls(chrome::CONTROLS_PROMPT); + } KEY_CURSOR_LEFT => { editor.move_left(); screen.render_input(editor.as_slice(), editor.cursor()); @@ -1205,12 +1214,12 @@ fn browse_dashboard( return Ok(()); } KEY_F1 => { - dashboard.previous_tab(false); + dashboard.next_tab(false); load_dashboard(socket, inbound, received, &mut dashboard, rows)?; true } KEY_F3 => { - dashboard.next_tab(false); + dashboard.previous_tab(false); load_dashboard(socket, inbound, received, &mut dashboard, rows)?; true } @@ -1464,7 +1473,7 @@ fn edit_connection_field(editor: &mut PromptEditor, key: u8, } } -fn browse_scrollback(screen: &mut Screen, editor: &PromptEditor) { +fn browse_scrollback(screen: &mut Screen) { chrome::set_controls(chrome::CONTROLS_SCROLL); let mut view = screen.begin_scrollback(); @@ -1482,8 +1491,7 @@ fn browse_scrollback(screen: &mut Screen, editor: &PromptEditor) { match poll_key!() { KEY_CLEAR => { screen.clear_transcript(); - screen.restore_composer(editor.as_slice(), editor.cursor()); - chrome::set_controls(chrome::CONTROLS_PROMPT); + screen.restore_live_transcript(); screen.set_status(b"TRANSCRIPT CLEARED"); return; } @@ -1499,8 +1507,6 @@ fn browse_scrollback(screen: &mut Screen, editor: &PromptEditor) { } KEY_RUN_STOP | KEY_F3 => { screen.restore_live_transcript(); - screen.restore_composer(editor.as_slice(), editor.cursor()); - chrome::set_controls(chrome::CONTROLS_PROMPT); return; } _ => false, diff --git a/client/src/screen.rs b/client/src/screen.rs index 73d2ebe..7d28281 100644 --- a/client/src/screen.rs +++ b/client/src/screen.rs @@ -1,7 +1,7 @@ use core::ptr::write_volatile; use crate::{ - dashboard::{Dashboard, DashboardRows, PAGE_ROWS}, + dashboard::{Dashboard, DashboardRows, PAGE_ROWS, TAB_BAR}, glyph::screen_code, scrollback::{ScrollbackView, VIEW_ROWS}, session::SessionList, @@ -180,8 +180,9 @@ impl Screen { pub fn render_dashboard(&mut self, dashboard: &Dashboard, rows: &DashboardRows) { self.transcript.set_visible(false); self.clear(); - self.write_color(0, 0, b"DASHBOARD ", CYAN); - self.write_color(0, 10, dashboard.tab.label(), YELLOW); + self.write_color(0, 0, TAB_BAR, CYAN); + let (tab_start, tab_end) = dashboard.tab.span(); + self.write_color(0, tab_start, &TAB_BAR[tab_start..tab_end], YELLOW); let first = dashboard.page_start(); let mut offset = 0usize; while offset < PAGE_ROWS { @@ -370,10 +371,11 @@ impl Screen { let first = self.transcript.count().saturating_sub(VIEW_ROWS); let mut output_row = TRANSCRIPT_FIRST_ROW; while output_row <= TRANSCRIPT_LAST_ROW { - self.clear_row(output_row); let line = first + output_row; if line < self.transcript.count() { self.render_scrollback_line(line, output_row); + } else { + self.clear_row(output_row); } output_row += 1; }