#include "board.h"
#include "approval_control.h"
#include "speech_control.h"
#include "mbedtls/base64.h"
#include "button_gesture.h"
#include "shell_input_policy.h"
#include "agent_navigator.h"
#include "agent_target.h"
#include "agent_display.h"
#include "page_redraw_policy.h"
#include "ui_state.h"
#include "wearable_renderer.h"
#include "device_status.h"
#include "device_preferences_nvs.h"
#include "wearable_fonts.h"
#include "rx8130_rtc.h"
#include "cst820_touch.h"
#include "touch_gesture.h"
#include "touch_transform.h"
#include "agent_history.h"
#include "history_view.h"
#include "esp_wifi.h"
#include <atomic>
#include <ctime>
#include "cJSON.h"
#include "esp_random.h"
#include <deque>

#include <algorithm>
#include <array>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>

#include "audio_backend.h"
#include "M5GFX.h"
#include "transcript_font.h"
#include "M5IOE1.h"
#include "M5PM1.h"
#include "driver/gpio.h"
#include "driver/i2c_master.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_psram.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "lgfx/v1/panel/Panel_AMOLED.hpp"

namespace {

constexpr char TAG[] = "BOARD";
const TranscriptFont s_transcript_font;
const wearable::FontBook s_fonts;

constexpr gpio_num_t I2C_SDA_PIN = GPIO_NUM_47;
constexpr gpio_num_t I2C_SCL_PIN = GPIO_NUM_48;
constexpr i2c_port_num_t I2C_PORT = I2C_NUM_0;

constexpr uint8_t PMIC_ADDRESS = 0x6e;
constexpr m5pm1_gpio_num_t PMG2_CHG_STAT = M5PM1_GPIO_NUM_2;
constexpr m5pm1_gpio_num_t PMG3_CHG_PROG = M5PM1_GPIO_NUM_3;
constexpr uint8_t IOE_PRIMARY_ADDRESS = 0x4f;
constexpr uint8_t IOE_SECONDARY_ADDRESS = 0x6f;
constexpr uint8_t RTC_ADDRESS = 0x32;
constexpr uint8_t IMU_ADDRESS = 0x68;
constexpr uint8_t TOUCH_ADDRESS = 0x15;

constexpr uint8_t MOTOR_PIN = M5IOE1_PIN_9;
constexpr uint8_t L3B_ENABLE_PIN = M5IOE1_PIN_8;
constexpr uint8_t SPEAKER_ENABLE_PIN = M5IOE1_PIN_10;
constexpr uint8_t TOUCH_RESET_PIN = M5IOE1_PIN_4;
constexpr uint8_t OLED_RESET_PIN = M5IOE1_PIN_5;
constexpr uint8_t MUX_CONTROL_PIN = M5IOE1_PIN_1;
constexpr uint8_t AUDIO_ENABLE_PIN = M5IOE1_PIN_3;
constexpr uint8_t MOTOR_PWM_CHANNEL = 0;
constexpr gpio_num_t SPEAKER_PA_PIN = GPIO_NUM_14;

constexpr uint8_t GENERIC_VIBRATION_DUTY = 75;
constexpr uint8_t NOTIFY_VIBRATION_DUTY = 90;
// The M5IOE1 PWM/I2C/logging call chain overflowed pi_notify's 2 KiB
// stack on physical DONE delivery. ESP-IDF task stack sizes are bytes.
constexpr uint32_t VIBRATION_TASK_STACK_BYTES = 4096;
struct VibrationStep {
    uint16_t on_ms;
    uint16_t off_ms;
};
constexpr std::array<VibrationStep, 2> DONE_VIBRATION_PATTERN = {{{250, 100}, {180, 0}}};
constexpr std::array<VibrationStep, 3> FAILED_VIBRATION_PATTERN = {
    {{240, 120}, {240, 120}, {240, 0}}};

constexpr std::array<VibrationStep, 2> APPROVAL_VIBRATION_PATTERN = {{{200, 100}, {200, 0}}};

constexpr size_t PI_LOCAL_TAIL_BYTES = 6 * 1024;
constexpr size_t PI_RENDER_SOURCE_BYTES = 1400;
constexpr uint32_t PI_REDRAW_MIN_INTERVAL_MS = 250;

constexpr gpio_num_t BUTTON_A_PIN = GPIO_NUM_2;
constexpr gpio_num_t BUTTON_B_PIN = GPIO_NUM_1;
constexpr gpio_num_t TOUCH_INT_PIN = GPIO_NUM_13;
// Movement sampling window while a finger stays on the panel.
constexpr uint64_t TOUCH_SAMPLE_INTERVAL_MS = 25;

constexpr gpio_num_t DISPLAY_SCLK_PIN = GPIO_NUM_40;
constexpr gpio_num_t DISPLAY_IO0_PIN = GPIO_NUM_41;
constexpr gpio_num_t DISPLAY_IO1_PIN = GPIO_NUM_42;
constexpr gpio_num_t DISPLAY_IO2_PIN = GPIO_NUM_46;
constexpr gpio_num_t DISPLAY_IO3_PIN = GPIO_NUM_45;
constexpr gpio_num_t DISPLAY_CS_PIN = GPIO_NUM_39;
constexpr gpio_num_t DISPLAY_TE_PIN = GPIO_NUM_38;

class StopWatchDisplay final : public M5GFX {
public:
    bool init_impl(bool use_reset, bool use_clear) override
    {
        auto bus_config = bus_.config();
        bus_config.freq_write = 80000000;
        bus_config.freq_read = 10000000;
        bus_config.pin_sclk = DISPLAY_SCLK_PIN;
        bus_config.pin_io0 = DISPLAY_IO0_PIN;
        bus_config.pin_io1 = DISPLAY_IO1_PIN;
        bus_config.pin_io2 = DISPLAY_IO2_PIN;
        bus_config.pin_io3 = DISPLAY_IO3_PIN;
        bus_config.spi_host = SPI2_HOST;
        bus_config.spi_mode = 0;
        bus_config.spi_3wire = true;
        bus_config.dma_channel = SPI_DMA_CH_AUTO;
        bus_.config(bus_config);
        panel_.setBus(&bus_);

        auto panel_config = panel_.config();
        panel_config.pin_rst = GPIO_NUM_NC;
        panel_config.pin_cs = DISPLAY_CS_PIN;
        panel_config.panel_width = 468;
        panel_config.panel_height = 466;
        panel_config.offset_x = 6;
        panel_config.offset_y = 0;
        panel_config.readable = false;
        panel_config.bus_shared = false;
        panel_.config(panel_config);
        setPanel(&panel_);

        lgfx::pinMode(DISPLAY_TE_PIN, lgfx::pin_mode_t::input_pullup);
        if (!LGFX_Device::init_impl(use_reset, use_clear)) {
            return false;
        }
        if (!panel_.initPanelFb()) {
            return false;
        }

        auto *framebuffer = panel_.getPanelFb();
        if (framebuffer == nullptr) {
            return false;
        }
        framebuffer->setBus(&bus_);
        framebuffer->setAutoDisplay(true);
        setPanel(framebuffer);
        panel_.setBrightness(128);
        return true;
    }

    void setPanelBrightness(uint8_t brightness)
    {
        panel_.setBrightness(brightness);
    }

private:
    lgfx::Bus_SPI bus_;
    lgfx::Panel_CO5300 panel_;
};


SemaphoreHandle_t s_display_lock;
TaskHandle_t s_display_owner;
std::atomic<bool> s_draw_requested{false};
char s_version[24] = "unknown";
char s_device_id[24] = "pending";
char s_wifi_detail[24] = "disconnected";
char s_last_error[96] = "not initialized";
bool s_wifi_connected;
bool s_cloud_connected;
bool s_display_ready;
bool s_vibrator_ready;
bool s_audio_ready;
i2c_master_bus_handle_t s_i2c_bus;
M5PM1 s_pmic;
M5IOE1 s_ioe;
Rx8130Rtc s_rtc;
uint64_t s_rtc_boot_utc_ms=0;
StopWatchDisplay s_display;
ButtonGesture s_button_a;
ButtonGesture s_button_b;
Cst820Touch s_touch;
wearable::TouchGestureReducer s_touch_reducer;
wearable::TouchTransform s_touch_transform;
// GPIO13 ISR only publishes a level change; I2C is never read in an ISR.
std::atomic<bool> s_touch_irq{false};
std::atomic<bool> s_touch_down{false};
bool s_touch_ready = false;
uint64_t s_touch_last_sample = 0;
// One-shot coordinate diagnostics so a physical test can verify orientation.
bool s_touch_diag_top=false, s_touch_diag_bottom=false, s_touch_diag_left=false,
     s_touch_diag_right=false, s_touch_diag_center=false;
ApprovalControl s_approval;
ApprovalAction s_approval_action;
std::string s_notified_approval;

using Page = DisplayPage;

struct ServerState {
    bool valid = false;
    char hostname[64] = "LIGHTHOUSE";
    float cpu_percent = 0;
    float load_1m = 0;
    float load_5m = 0;
    uint64_t memory_used = 0;
    uint64_t memory_total = 0;
    float memory_percent = 0;
    float swap_percent = 0;
    uint64_t disk_used = 0;
    uint64_t disk_total = 0;
    float disk_percent = 0;
    uint64_t network_rx_bytes = 0;
    uint64_t network_tx_bytes = 0;
    float network_rx_bps = 0;
    float network_tx_bps = 0;
    uint64_t uptime = 0;
    // Derived glance state. Codex quota is cached, never requested by the device.
    wearable::HostState host = wearable::HostState::Offline;
    char host_state[16] = "";
    bool has_host_state = false;
    wearable::CodexQuota codex;
    char codex_reset_text[24] = "";
};

struct PiState {
    bool selected = false;
    char agent[16] = "pi";
    char model[256] = "";
    char effort[64] = "";
    char session_id[161] = "";
    char name[64] = "";
    char cwd[128] = "";
    char state[24] = "OFFLINE";
    char activity_type[16] = "";
    // Gateway caps latest_activity at 480 UTF-8 bytes; keep the whole value so
    // strlcpy never cuts a multibyte character used by the tool view.
    char latest_activity[512] = "";
    bool has_final_text = false;
    char final_text[AGENT_FINAL_MAX_BYTES + 1] = "";
    uint64_t elapsed_seconds = 0;
    uint64_t received_at_ms = 0;
};

ServerState s_server;
PiState s_pi;
Page s_page = Page::PI; // Compatibility backing metric; shell owns navigation.
wearable::UiState s_ui;
wearable::LoadedPreferences s_saved_preferences;
wearable::WallClock s_clock;
wearable::BatteryStatus s_battery;
uint64_t s_next_power_log_ms=0;
void probe_battery(bool initial=false);
std::atomic<bool> s_vibration_enabled{true};
int s_wifi_rssi=-127;
std::string s_preference_feedback;
uint64_t s_last_shell_tick=0;
uint64_t s_last_watch_clock_minute=0,s_last_watch_elapsed=0;

void sync_display_page() {
    s_page=s_ui.app==wearable::App::Monitor?Page::OVERVIEW:
        s_ui.app==wearable::App::Help?Page::COUNT:Page::PI;
}
// Shared shell app identity for redraw policy; keeps page/App mapping in one place.
ShellApp shell_app() {
    return s_ui.app==wearable::App::Monitor?ShellApp::Monitor:
        s_ui.app==wearable::App::Help?ShellApp::Help:
        s_ui.app==wearable::App::Agent?ShellApp::Agent:ShellApp::Watch;
}
bool s_message_visible;
uint64_t s_message_until_ms;
std::string s_message_title,s_message_body;
uint64_t s_last_pi_redraw_ms;
uint64_t s_last_pi_elapsed_second;
bool s_pi_redraw_pending;
bool s_watch_task_redraw_pending;
char *s_pi_tail;
size_t s_pi_tail_length;

void set_last_error(const char *stage, int code)
{
    std::snprintf(s_last_error, sizeof(s_last_error), "%s failed (code=%d)", stage, code);
}

bool address_present(uint8_t address)
{
    return s_i2c_bus != nullptr && i2c_master_probe(s_i2c_bus, address, 20) == ESP_OK;
}

esp_err_t initialize_i2c()
{
    i2c_master_bus_config_t config = {};
    config.i2c_port = I2C_PORT;
    config.sda_io_num = I2C_SDA_PIN;
    config.scl_io_num = I2C_SCL_PIN;
    config.clk_source = I2C_CLK_SRC_DEFAULT;
    config.glitch_ignore_cnt = 7;
    config.flags.enable_internal_pullup = true;

    const esp_err_t result = i2c_new_master_bus(&config, &s_i2c_bus);
    if (result != ESP_OK) {
        ESP_LOGE(TAG, "[DIAG] I2C init GPIO47(SDA)/GPIO48(SCL): FAIL code=%s (%d)",
                 esp_err_to_name(result), result);
        set_last_error("I2C init", result);
        return result;
    }

    char detected[384] = {};
    size_t used = 0;
    unsigned count = 0;
    for (uint8_t address = 0x08; address <= 0x77; ++address) {
        const esp_err_t probe = i2c_master_probe(s_i2c_bus, address, 20);
        if (probe == ESP_OK) {
            const int written = std::snprintf(detected + used, sizeof(detected) - used,
                                              "%s0x%02x", count == 0 ? "" : " ", address);
            if (written > 0 && static_cast<size_t>(written) < sizeof(detected) - used) {
                used += static_cast<size_t>(written);
            }
            ++count;
        }
    }
    ESP_LOGI(TAG, "[DIAG] I2C scan GPIO47(SDA)/GPIO48(SCL): count=%u addresses=%s",
             count, count == 0 ? "none" : detected);
    ESP_LOGI(TAG,
             "[DIAG] I2C roles: touch(0x15)=%s rtc(0x32)=%s imu(0x68)=%s pmic(0x6e)=%s ioe(0x4f/0x6f)=%s/%s",
             address_present(TOUCH_ADDRESS) ? "ACK" : "MISS",
             address_present(RTC_ADDRESS) ? "ACK" : "MISS",
             address_present(IMU_ADDRESS) ? "ACK" : "MISS",
             address_present(PMIC_ADDRESS) ? "ACK" : "MISS",
             address_present(IOE_PRIMARY_ADDRESS) ? "ACK" : "MISS",
             address_present(IOE_SECONDARY_ADDRESS) ? "ACK" : "MISS");
    return ESP_OK;
}

void initialize_rtc_nonfatal() {
    auto result=s_rtc.begin(s_i2c_bus,RTC_ADDRESS);
    if(result==ESP_OK) result=s_rtc.read_utc_ms(s_rtc_boot_utc_ms);
    ESP_LOGI(TAG,"[TIME] RX8130 init=%s boot_utc_ms=%llu",
        esp_err_to_name(result),static_cast<unsigned long long>(s_rtc_boot_utc_ms));
    if(result!=ESP_OK) s_rtc_boot_utc_ms=0;
}

esp_err_t initialize_pmic()
{
    const m5pm1_err_t begin_result = s_pmic.begin(s_i2c_bus, PMIC_ADDRESS, M5PM1_I2C_FREQ_100K);
    if (begin_result != M5PM1_OK) {
        ESP_LOGE(TAG, "[DIAG] M5PM1 init addr=0x%02x: FAIL code=%d", PMIC_ADDRESS,
                 static_cast<int>(begin_result));
        set_last_error("M5PM1 init", static_cast<int>(begin_result));
        return ESP_ERR_NOT_FOUND;
    }

    uint8_t device_id = 0;
    uint8_t device_model = 0;
    const m5pm1_err_t id_result = s_pmic.getDeviceId(&device_id);
    const m5pm1_err_t model_result = s_pmic.getDeviceModel(&device_model);
    if (id_result != M5PM1_OK || model_result != M5PM1_OK ||
        device_id != 0x50 || device_model != 0x20) {
        ESP_LOGE(TAG,
                 "[DIAG] M5PM1 identity: FAIL id=0x%02x model=0x%02x id_code=%d model_code=%d expected=0x50/0x20",
                 device_id, device_model, static_cast<int>(id_result),
                 static_cast<int>(model_result));
        set_last_error("M5PM1 identity", ESP_ERR_INVALID_RESPONSE);
        return ESP_ERR_INVALID_RESPONSE;
    }

    const m5pm1_err_t sleep_1 = s_pmic.setI2cSleepTime(0);
    const m5pm1_err_t sleep_2 = s_pmic.setI2cSleepTime(0);
    const m5pm1_err_t watchdog = s_pmic.wdtSet(0);
    constexpr uint8_t required_power = M5PM1_PWR_CFG_CHG_EN | M5PM1_PWR_CFG_DCDC_EN |
                                       M5PM1_PWR_CFG_LDO_EN | M5PM1_PWR_CFG_LED_CTRL;
    const m5pm1_err_t power = s_pmic.setPowerConfig(required_power, required_power);
    const m5pm1_err_t hold = s_pmic.ldoSetPowerHold(true);
    if (sleep_1 != M5PM1_OK || sleep_2 != M5PM1_OK || watchdog != M5PM1_OK ||
        power != M5PM1_OK || hold != M5PM1_OK) {
        ESP_LOGE(TAG,
                 "[DIAG] M5PM1 core power setup: FAIL sleep=%d/%d wdt=%d power=%d hold=%d",
                 static_cast<int>(sleep_1), static_cast<int>(sleep_2),
                 static_cast<int>(watchdog), static_cast<int>(power), static_cast<int>(hold));
        set_last_error("M5PM1 core power setup", ESP_FAIL);
        return ESP_FAIL;
    }

    // Match official StopWatch PMIC charge programming and status input.
    const auto charge=s_pmic.setChargeEnable(true);
    const auto prog=s_pmic.gpioSet(PMG3_CHG_PROG,M5PM1_GPIO_MODE_OUTPUT,0,
        M5PM1_GPIO_PULL_NONE,M5PM1_GPIO_DRIVE_PUSHPULL);
    const auto stat_func=s_pmic.gpioSetFunc(PMG2_CHG_STAT,M5PM1_GPIO_FUNC_GPIO);
    const auto stat_mode=s_pmic.gpioSetMode(PMG2_CHG_STAT,M5PM1_GPIO_MODE_INPUT);
    const auto stat_pull=s_pmic.gpioSetPull(PMG2_CHG_STAT,M5PM1_GPIO_PULL_NONE);
    ESP_LOGI(TAG,"[POWER] charge_init=%d prog=%d stat_func=%d stat_mode=%d stat_pull=%d",
        int(charge),int(prog),int(stat_func),int(stat_mode),int(stat_pull));
    const m5pm1_err_t button = s_pmic.btnSetConfig(M5PM1_BTN_TYPE_CLICK, M5PM1_BTN_CLICK_DELAY_1000MS);
    const m5pm1_err_t reset = s_pmic.setSingleResetDisable(true);
    if (button != M5PM1_OK || reset != M5PM1_OK) {
        ESP_LOGW(TAG, "[DIAG] M5PM1 optional button config: FAIL button=%d reset=%d",
                 static_cast<int>(button), static_cast<int>(reset));
    }
    ESP_LOGI(TAG,
             "[DIAG] M5PM1 init addr=0x%02x: OK code=%d id=0x%02x model=0x%02x core_power=OK",
             PMIC_ADDRESS, static_cast<int>(begin_result), device_id, device_model);
    return ESP_OK;
}

bool configure_ioe_pin(uint8_t pin, uint8_t mode, uint8_t value, const char *name)
{
    m5ioe1_err_t mode_result = M5IOE1_OK;
    m5ioe1_err_t write_result = M5IOE1_OK;
    s_ioe.pinModeWithRes(pin, mode, &mode_result);
    s_ioe.digitalWriteWithRes(pin, value, &write_result);
    if (mode_result != M5IOE1_OK || write_result != M5IOE1_OK) {
        ESP_LOGE(TAG, "[DIAG] M5IOE1 pin %s(%u): FAIL mode=%d write=%d", name, pin,
                 static_cast<int>(mode_result), static_cast<int>(write_result));
        return false;
    }
    return true;
}

esp_err_t initialize_ioe()
{
    uint8_t selected_address = IOE_PRIMARY_ADDRESS;
    m5ioe1_err_t begin_result = s_ioe.begin(s_i2c_bus, selected_address, M5IOE1_I2C_FREQ_400K,
                                             M5IOE1_INT_MODE_DISABLED);
    const m5ioe1_err_t primary_result = begin_result;
    if (begin_result != M5IOE1_OK) {
        selected_address = IOE_SECONDARY_ADDRESS;
        begin_result = s_ioe.begin(s_i2c_bus, selected_address, M5IOE1_I2C_FREQ_400K,
                                   M5IOE1_INT_MODE_DISABLED);
    }
    if (begin_result != M5IOE1_OK) {
        ESP_LOGE(TAG, "[DIAG] M5IOE1 init: FAIL addr=0x%02x code=%d addr=0x%02x code=%d",
                 IOE_PRIMARY_ADDRESS, static_cast<int>(primary_result), IOE_SECONDARY_ADDRESS,
                 static_cast<int>(begin_result));
        set_last_error("M5IOE1 init", static_cast<int>(begin_result));
        return ESP_ERR_NOT_FOUND;
    }

    uint16_t ioe_uid = 0;
    const m5ioe1_err_t uid_result = s_ioe.getUID(&ioe_uid);
    if (uid_result != M5IOE1_OK) {
        ESP_LOGE(TAG, "[DIAG] M5IOE1 identity addr=0x%02x: FAIL code=%d", selected_address,
                 static_cast<int>(uid_result));
        set_last_error("M5IOE1 identity", static_cast<int>(uid_result));
        return ESP_ERR_INVALID_RESPONSE;
    }

    const m5ioe1_err_t sleep_1 = s_ioe.setI2cSleepTime(0);
    const m5ioe1_err_t sleep_2 = s_ioe.setI2cSleepTime(0);
    const bool l3b_ok = configure_ioe_pin(L3B_ENABLE_PIN, OUTPUT, HIGH, "L3B_EN");
    const bool oled_ok = configure_ioe_pin(OLED_RESET_PIN, OUTPUT, HIGH, "OLED_RST");
    if (sleep_1 != M5IOE1_OK || sleep_2 != M5IOE1_OK || !l3b_ok || !oled_ok) {
        ESP_LOGE(TAG, "[DIAG] M5IOE1 display power setup: FAIL sleep=%d/%d l3b=%s oled=%s",
                 static_cast<int>(sleep_1), static_cast<int>(sleep_2),
                 l3b_ok ? "OK" : "FAIL", oled_ok ? "OK" : "FAIL");
        set_last_error("M5IOE1 display power setup", ESP_FAIL);
        return ESP_FAIL;
    }

    m5ioe1_err_t l3b_read_result = M5IOE1_OK;
    int l3b_level = -1;
    for (int attempt = 1; attempt <= 3; ++attempt) {
        vTaskDelay(pdMS_TO_TICKS(80));
        l3b_level = s_ioe.digitalReadWithRes(L3B_ENABLE_PIN, &l3b_read_result);
        if (l3b_read_result == M5IOE1_OK && l3b_level == HIGH) {
            break;
        }
        m5ioe1_err_t retry_write = M5IOE1_OK;
        s_ioe.digitalWriteWithRes(L3B_ENABLE_PIN, HIGH, &retry_write);
        ESP_LOGW(TAG, "[DIAG] L3B_EN readback retry=%d level=%d read_code=%d write_code=%d",
                 attempt, l3b_level, static_cast<int>(l3b_read_result),
                 static_cast<int>(retry_write));
    }
    if (l3b_read_result != M5IOE1_OK || l3b_level != HIGH) {
        ESP_LOGE(TAG, "[DIAG] L3B_EN power rail: FAIL level=%d code=%d", l3b_level,
                 static_cast<int>(l3b_read_result));
        set_last_error("L3B_EN power rail", static_cast<int>(l3b_read_result));
        return ESP_FAIL;
    }
    ESP_LOGI(TAG, "[DIAG] L3B_EN power rail: OK level=%d code=%d", l3b_level,
             static_cast<int>(l3b_read_result));

    m5ioe1_err_t reset_low = M5IOE1_OK;
    m5ioe1_err_t reset_high = M5IOE1_OK;
    s_ioe.digitalWriteWithRes(OLED_RESET_PIN, LOW, &reset_low);
    vTaskDelay(pdMS_TO_TICKS(10));
    s_ioe.digitalWriteWithRes(OLED_RESET_PIN, HIGH, &reset_high);
    if (reset_low != M5IOE1_OK || reset_high != M5IOE1_OK) {
        ESP_LOGE(TAG, "[DIAG] OLED reset pulse: FAIL low=%d high=%d",
                 static_cast<int>(reset_low), static_cast<int>(reset_high));
        set_last_error("OLED reset pulse", ESP_FAIL);
        return ESP_FAIL;
    }

    const bool touch_reset_ok = configure_ioe_pin(TOUCH_RESET_PIN, OUTPUT, HIGH, "TP_RST");
    const bool speaker_ok = configure_ioe_pin(SPEAKER_ENABLE_PIN, OUTPUT, LOW, "SPK_PA");
    const bool mux_ok = configure_ioe_pin(MUX_CONTROL_PIN, OUTPUT, LOW, "MUX_CTR");
    const bool audio_ok = configure_ioe_pin(AUDIO_ENABLE_PIN, OUTPUT, HIGH, "AUDIO_EN");
    if (!touch_reset_ok || !speaker_ok || !mux_ok || !audio_ok) {
        ESP_LOGW(TAG, "[DIAG] M5IOE1 optional peripherals: touch_reset=%s speaker=%s mux=%s audio=%s",
                 touch_reset_ok ? "OK" : "FAIL", speaker_ok ? "OK" : "FAIL",
                 mux_ok ? "OK" : "FAIL", audio_ok ? "OK" : "FAIL");
    }
    const esp_err_t speaker_direction = gpio_set_direction(SPEAKER_PA_PIN, GPIO_MODE_OUTPUT);
    const esp_err_t speaker_level = gpio_set_level(SPEAKER_PA_PIN, LOW);
    if (speaker_direction != ESP_OK || speaker_level != ESP_OK) {
        ESP_LOGW(TAG, "[DIAG] speaker PA GPIO14 setup: FAIL direction=%d level=%d (nonfatal)",
                 speaker_direction, speaker_level);
    }

    m5ioe1_err_t motor_mode = M5IOE1_OK;
    s_ioe.pinModeWithRes(MOTOR_PIN, OUTPUT, &motor_mode);
    const m5ioe1_err_t motor_frequency = s_ioe.setPwmFrequency(5000);
    const m5ioe1_err_t motor_duty = s_ioe.setPwmDuty(MOTOR_PWM_CHANNEL, 0, false, true);
    s_vibrator_ready = motor_mode == M5IOE1_OK && motor_frequency == M5IOE1_OK &&
                       motor_duty == M5IOE1_OK;
    if (!s_vibrator_ready) {
        ESP_LOGW(TAG, "[DIAG] vibration init: FAIL pin=%d frequency=%d duty=%d (nonfatal)",
                 static_cast<int>(motor_mode), static_cast<int>(motor_frequency),
                 static_cast<int>(motor_duty));
    } else {
        ESP_LOGI(TAG, "[DIAG] vibration init: OK addr=0x%02x", selected_address);
    }

    ESP_LOGI(TAG, "[DIAG] M5IOE1 init addr=0x%02x: OK code=%d uid=0x%04x display_power=OK",
             selected_address, static_cast<int>(begin_result), ioe_uid);
    return ESP_OK;
}

esp_err_t initialize_display()
{
    const bool initialized = s_display.init();
    const int width = s_display.width();
    const int height = s_display.height();
    if (!initialized || width != 468 || height != 466) {
        ESP_LOGE(TAG, "[DIAG] CO5300 display init: FAIL init=%s size=%dx%d expected=468x466",
                 initialized ? "true" : "false", width, height);
        set_last_error("CO5300 display init", ESP_FAIL);
        return ESP_FAIL;
    }
    s_display.setRotation(0);
    s_display.setPanelBrightness(128);
    s_display_ready = true;
    ESP_LOGI(TAG, "[DIAG] CO5300 display init: OK qspi=80MHz size=%dx%d framebuffer=PSRAM", width,
             height);
    return ESP_OK;
}

void diagnose_touch_and_optional_i2c()
{
    m5ioe1_err_t reset_low = M5IOE1_OK;
    m5ioe1_err_t reset_high = M5IOE1_OK;
    s_ioe.digitalWriteWithRes(TOUCH_RESET_PIN, LOW, &reset_low);
    vTaskDelay(pdMS_TO_TICKS(10));
    s_ioe.digitalWriteWithRes(TOUCH_RESET_PIN, HIGH, &reset_high);
    vTaskDelay(pdMS_TO_TICKS(50));
    const bool reset_ok = reset_low == M5IOE1_OK && reset_high == M5IOE1_OK;

    ESP_LOGI(TAG, "[DIAG] touch CST820 addr=0x%02x: %s reset=%s (nonfatal)", TOUCH_ADDRESS,
             address_present(TOUCH_ADDRESS) ? "ACK" : "MISS", reset_ok ? "OK" : "FAIL");
    ESP_LOGI(TAG, "[DIAG] optional I2C: RTC(0x%02x)=%s IMU(0x%02x)=%s (nonfatal)",
             RTC_ADDRESS, address_present(RTC_ADDRESS) ? "ACK" : "MISS", IMU_ADDRESS,
             address_present(IMU_ADDRESS) ? "ACK" : "MISS");
}

void initialize_buttons()
{
    gpio_reset_pin(BUTTON_A_PIN);
    gpio_set_direction(BUTTON_A_PIN, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BUTTON_A_PIN, GPIO_PULLUP_ONLY);
    gpio_reset_pin(BUTTON_B_PIN);
    gpio_set_direction(BUTTON_B_PIN, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BUTTON_B_PIN, GPIO_PULLUP_ONLY);
    ESP_LOGI(TAG, "[DIAG] buttons init: OK A=GPIO2 B=GPIO1");
}

// CST820B interrupt: only publish that the controller wants attention. The
// debounce and I2C read happen in board_update, never in the ISR.
static void IRAM_ATTR touch_isr(void*)
{
    s_touch_irq.store(true, std::memory_order_relaxed);
}

// Touch failure is nonfatal: every button-only path stays fully functional.
void initialize_touch_nonfatal()
{
    // TP_RST is driven through M5IOE1; pulse it before probing the controller.
    m5ioe1_err_t reset_low = M5IOE1_OK, reset_high = M5IOE1_OK;
    s_ioe.digitalWriteWithRes(TOUCH_RESET_PIN, LOW, &reset_low);
    vTaskDelay(pdMS_TO_TICKS(10));
    s_ioe.digitalWriteWithRes(TOUCH_RESET_PIN, HIGH, &reset_high);
    vTaskDelay(pdMS_TO_TICKS(50));

    const esp_err_t result = s_touch.begin(s_i2c_bus);
    if (result != ESP_OK) {
        ESP_LOGW(TAG, "[TOUCH] init FAIL addr=0x%02x code=%s reset=%s (nonfatal, buttons unaffected)",
                 Cst820Touch::ADDRESS, esp_err_to_name(result),
                 (reset_low == M5IOE1_OK && reset_high == M5IOE1_OK) ? "OK" : "FAIL");
        return;
    }
    gpio_config_t config{};
    config.pin_bit_mask = 1ULL << TOUCH_INT_PIN;
    config.mode = GPIO_MODE_INPUT;
    config.pull_up_en = GPIO_PULLUP_ENABLE;
    config.intr_type = GPIO_INTR_NEGEDGE;
    if (gpio_config(&config) != ESP_OK ||
        gpio_install_isr_service(0) != ESP_OK ||
        gpio_isr_handler_add(TOUCH_INT_PIN, touch_isr, nullptr) != ESP_OK) {
        ESP_LOGW(TAG, "[TOUCH] init FAIL GPIO%d interrupt setup (nonfatal)", int(TOUCH_INT_PIN));
        s_touch = Cst820Touch{};
        return;
    }
    s_touch_ready = true;
    ESP_LOGI(TAG, "[TOUCH] init OK CST820B addr=0x%02x int=GPIO%d raw=0..%u display=%ux%u",
             Cst820Touch::ADDRESS, int(TOUCH_INT_PIN), s_touch_transform.raw_max_x,
             s_touch_transform.display_width, s_touch_transform.display_height);
}

void initialize_audio_nonfatal()
{
    const esp_err_t codec_result = audio_backend_init(s_i2c_bus);
    if (codec_result != ESP_OK) {
        ESP_LOGW(TAG, "[AUDIO] init failed at ES8311/I2S: %s (%d) (nonfatal)",
                 esp_err_to_name(codec_result), codec_result);
        return;
    }

    m5ioe1_err_t ioe_result = M5IOE1_OK;
    s_ioe.digitalWriteWithRes(SPEAKER_ENABLE_PIN, HIGH, &ioe_result);
    const esp_err_t gpio_result = gpio_set_level(SPEAKER_PA_PIN, HIGH);
    if (ioe_result != M5IOE1_OK || gpio_result != ESP_OK) {
        s_ioe.digitalWrite(SPEAKER_ENABLE_PIN, LOW);
        gpio_set_level(SPEAKER_PA_PIN, LOW);
        ESP_LOGW(TAG, "[AUDIO] init failed at AW8737A enable: ioe=%d gpio=%d (nonfatal)",
                 static_cast<int>(ioe_result), gpio_result);
        return;
    }
    vTaskDelay(pdMS_TO_TICKS(10));
    s_audio_ready = audio_backend_ready();
    ESP_LOGI(TAG,
             "[AUDIO] init OK ES8311=ready I2S0=44.1kHz/16-bit MCLK=18 BCLK=17 LRCK=15 DOUT=21 AW8737A=enabled");
}

void lock_display()
{
    if (s_display_lock != nullptr) {
        xSemaphoreTakeRecursive(s_display_lock, portMAX_DELAY);
    }
}

void unlock_display()
{
    if (s_display_lock != nullptr) {
        xSemaphoreGiveRecursive(s_display_lock);
    }
}

uint16_t percent_color(float value)
{
    if (value >= 90) {
        return wearable::palette(s_ui.preferences.theme).failed;
    }
    if (value >= 75) {
        return wearable::palette(s_ui.preferences.theme).approval;
    }
    return wearable::palette(s_ui.preferences.theme).done;
}

void format_bytes(uint64_t bytes, char *buffer, size_t length)
{
    constexpr double GIB = 1024.0 * 1024.0 * 1024.0;
    constexpr double MIB = 1024.0 * 1024.0;
    if (bytes >= static_cast<uint64_t>(GIB)) {
        std::snprintf(buffer, length, "%.1f GiB", bytes / GIB);
    } else {
        std::snprintf(buffer, length, "%.1f MiB", bytes / MIB);
    }
}

void format_rate(float bytes_per_second, char *buffer, size_t length)
{
    if (bytes_per_second >= 1000.0F * 1000.0F) {
        std::snprintf(buffer, length, "%.1f MB/s", bytes_per_second / 1000000.0F);
    } else if (bytes_per_second >= 1000.0F) {
        std::snprintf(buffer, length, "%.0f KB/s", bytes_per_second / 1000.0F);
    } else {
        std::snprintf(buffer, length, "%.0f B/s", bytes_per_second);
    }
}

size_t utf8_boundary(const char *value, size_t length, size_t offset)
{
    offset = std::min(offset, length);
    while (offset < length &&
           (static_cast<unsigned char>(value[offset]) & 0xC0U) == 0x80U) {
        ++offset;
    }
    return offset;
}

void set_pi_tail(const char *value)
{
    if (s_pi_tail == nullptr) {
        return;
    }
    value = value == nullptr ? "" : value;
    const size_t length = std::strlen(value);
    size_t start = length > PI_LOCAL_TAIL_BYTES ? length - PI_LOCAL_TAIL_BYTES : 0;
    start = utf8_boundary(value, length, start);
    s_pi_tail_length = std::min(length - start, PI_LOCAL_TAIL_BYTES);
    std::memcpy(s_pi_tail, value + start, s_pi_tail_length);
    s_pi_tail[s_pi_tail_length] = '\0';
}

void append_pi_tail(const char *activity_type, const char *activity)
{
    if (s_pi_tail == nullptr || activity == nullptr || activity[0] == '\0') {
        return;
    }
    std::string entry;
    if (activity_type != nullptr && std::strcmp(activity_type, "tool") == 0) {
        entry = "\n[TOOL] ";
        entry += activity;
        entry += '\n';
    } else {
        entry = activity;
    }
    if (entry.size() >= PI_LOCAL_TAIL_BYTES) {
        set_pi_tail(entry.c_str());
        return;
    }
    if (s_pi_tail_length + entry.size() > PI_LOCAL_TAIL_BYTES) {
        size_t drop = s_pi_tail_length + entry.size() - PI_LOCAL_TAIL_BYTES;
        drop = utf8_boundary(s_pi_tail, s_pi_tail_length, drop);
        std::memmove(s_pi_tail, s_pi_tail + drop, s_pi_tail_length - drop);
        s_pi_tail_length -= drop;
    }
    std::memcpy(s_pi_tail + s_pi_tail_length, entry.data(), entry.size());
    s_pi_tail_length += entry.size();
    s_pi_tail[s_pi_tail_length] = '\0';
}

size_t utf8_character_size(const char *value, size_t remaining)
{
    if (remaining == 0) {
        return 0;
    }
    const unsigned char first = static_cast<unsigned char>(value[0]);
    size_t size = 1;
    if ((first & 0xE0U) == 0xC0U) size = 2;
    else if ((first & 0xF0U) == 0xE0U) size = 3;
    else if ((first & 0xF8U) == 0xF0U) size = 4;
    return std::min(size, remaining);
}

std::vector<std::string> wrap_activity_text(const char *value, int maximum_width, bool tail = true)
{
    std::vector<std::string> lines;
    if (value == nullptr || value[0] == '\0') {
        return lines;
    }
    const size_t length = std::strlen(value);
    size_t offset = tail && length > PI_RENDER_SOURCE_BYTES ? length - PI_RENDER_SOURCE_BYTES : 0;
    offset = utf8_boundary(value, length, offset);
    std::string current;
    while (offset < length) {
        if (value[offset] == '\n') {
            lines.push_back(current);
            current.clear();
            ++offset;
            continue;
        }
        const size_t character_size = utf8_character_size(value + offset, length - offset);
        std::string character(value + offset, character_size);
        std::string candidate = current + character;
        if (!current.empty() && s_display.textWidth(candidate.c_str()) > maximum_width) {
            lines.push_back(current);
            current = character == " " ? "" : character;
        } else if (!current.empty() || character != " ") {
            current = std::move(candidate);
        }
        offset += character_size;
    }
    if (!current.empty()) {
        lines.push_back(current);
    }
    return lines;
}

void draw_header(const char *title)
{
    s_fonts.apply(s_display,wearable::TypeRole::PageTitle,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    s_display.drawString(title, s_display.width() / 2, 38);
}

void draw_footer()
{
    const auto colors=wearable::palette(s_ui.preferences.theme);
    const char* hint="MONITOR";
    s_fonts.apply(s_display,wearable::TypeRole::Footer,s_ui.preferences.text_size,colors.accent,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);s_display.setTextColor(colors.accent,TFT_BLACK);
    s_display.drawString(hint,234,374);
    s_display.setTextColor(colors.secondary,TFT_BLACK);
    s_display.drawString(s_cloud_connected?"AGENT ONLINE":"GATEWAY RECONNECTING",234,404);
}

void draw_percent_row(const char *label, float value, int y)
{
    char text[20];
    std::snprintf(text, sizeof(text), "%.0f%%", value);
    s_display.setTextDatum(textdatum_t::middle_left);
    s_fonts.apply(s_display,wearable::TypeRole::AgentBodyLarge,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_left);
    s_display.drawString(label, 105, y);
    s_display.setTextDatum(textdatum_t::middle_right);
    s_display.setTextColor(percent_color(value), TFT_BLACK);
    s_display.drawString(text, 365, y);
}

void draw_progress(float value, int y)
{
    const int x = 85;
    const int width = 298;
    const int filled = static_cast<int>(std::clamp(value, 0.0F, 100.0F) * width / 100.0F);
    s_display.drawRoundRect(x, y, width, 20, 8, 0x4208);
    if (filled > 0) {
        s_display.fillRoundRect(x, y, filled, 20, 8, percent_color(value));
    }
}

wearable::ShellView shell_view();
void draw_overview()
{
    const auto view=shell_view();
    const wearable::MonitorView monitor{s_server.hostname,s_server.valid,s_server.cpu_percent,
        s_server.memory_percent,s_server.disk_percent,s_server.network_rx_bps,s_server.network_tx_bps};
    wearable::ShellRenderer<StopWatchDisplay,wearable::FontBook>(s_display,s_fonts,s_ui,view).monitor(monitor);
}

void draw_cpu()
{
    draw_header("CPU");
    draw_percent_row("USAGE", s_server.cpu_percent, 145);
    draw_progress(s_server.cpu_percent, 184);
    char text[48];
    s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "Load 1m   %.2f", s_server.load_1m);
    s_display.drawString(text, s_display.width() / 2, 245);
    std::snprintf(text, sizeof(text), "Load 5m   %.2f", s_server.load_5m);
    s_display.drawString(text, s_display.width() / 2, 286);
    std::snprintf(text, sizeof(text), "Uptime   %llud %02lluh",
                  static_cast<unsigned long long>(s_server.uptime / 86400),
                  static_cast<unsigned long long>((s_server.uptime / 3600) % 24));
    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).secondary, TFT_BLACK);
    s_display.drawString(text, s_display.width() / 2, 340);
}

void draw_memory()
{
    draw_header("MEMORY");
    draw_percent_row("USED", s_server.memory_percent, 145);
    draw_progress(s_server.memory_percent, 184);
    char used[24];
    char total[24];
    char text[64];
    format_bytes(s_server.memory_used, used, sizeof(used));
    format_bytes(s_server.memory_total, total, sizeof(total));
    s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "%s / %s", used, total);
    s_display.drawString(text, s_display.width() / 2, 248);
    std::snprintf(text, sizeof(text), "Swap   %.0f%%", s_server.swap_percent);
    s_display.setTextColor(percent_color(s_server.swap_percent), TFT_BLACK);
    s_display.drawString(text, s_display.width() / 2, 308);
}

void draw_disk()
{
    draw_header("DISK /");
    draw_percent_row("USED", s_server.disk_percent, 145);
    draw_progress(s_server.disk_percent, 184);
    char used[24];
    char total[24];
    char text[64];
    format_bytes(s_server.disk_used, used, sizeof(used));
    format_bytes(s_server.disk_total, total, sizeof(total));
    s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "%s / %s", used, total);
    s_display.drawString(text, s_display.width() / 2, 252);
    if (s_server.disk_percent >= 90) {
        s_display.setTextColor(wearable::palette(s_ui.preferences.theme).failed, TFT_BLACK);
        s_fonts.apply(s_display,wearable::TypeRole::StatusPrimary,s_ui.preferences.text_size,
                      wearable::palette(s_ui.preferences.theme).failed,TFT_BLACK);
        s_display.setTextDatum(textdatum_t::top_center);
        s_display.drawString("SPACE LOW", s_display.width() / 2, 315);
    }
}

void draw_network()
{
    draw_header("NETWORK");
    char rx_rate[24];
    char tx_rate[24];
    char rx_total[24];
    char tx_total[24];
    char text[64];
    format_rate(s_server.network_rx_bps, rx_rate, sizeof(rx_rate));
    format_rate(s_server.network_tx_bps, tx_rate, sizeof(tx_rate));
    format_bytes(s_server.network_rx_bytes, rx_total, sizeof(rx_total));
    format_bytes(s_server.network_tx_bytes, tx_total, sizeof(tx_total));
    s_fonts.apply(s_display,wearable::TypeRole::AgentBodyLarge,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).done,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "RX  %s", rx_rate);
    s_display.drawString(text, s_display.width() / 2, 130);
    s_fonts.apply(s_display,wearable::TypeRole::Meta,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "Total %s", rx_total);
    s_display.drawString(text, s_display.width() / 2, 190);
    s_fonts.apply(s_display,wearable::TypeRole::AgentBodyLarge,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).accent,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "TX  %s", tx_rate);
    s_display.drawString(text, s_display.width() / 2, 252);
    s_fonts.apply(s_display,wearable::TypeRole::Meta,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "Total %s", tx_total);
    s_display.drawString(text, s_display.width() / 2, 312);
}


InputModal s_input_modal = InputModal::NONE;
uint64_t s_ui_epoch = 1, s_voice_serial = 0, s_voice_started = 0, s_feedback_until = 0;
uint64_t s_modal_redraw = 0;
wearable::AgentNavigator s_nav;
std::string s_voice_context;
bool s_effort_settings=false,s_permission_settings=false;
wearable::VoiceSendMode s_voice_send_mode=wearable::VoiceSendMode::Confirm;
std::string s_voice_id, s_voice_phase, s_transcript, s_modal_error, s_settings_id;
std::string s_settings_phase, s_settings_target;
SpeechMenu s_speech_menu;
// Double A enters the Speech Mode page directly; APPLY returns to the Shell.
bool s_speech_direct=false;
// Bounded Agent history. Never a second conversation database on the device.
wearable::AgentHistoryCache s_history_cache;
wearable::HistoryScreen s_history_screen;
wearable::AgentBrowse s_agent_browse;
wearable::HistoryView s_history_view = wearable::HistoryView::Closed;
std::string s_history_request, s_history_error;
bool s_history_loading = false;
SpeechStream s_speech;
uint32_t s_speech_generation = 0;
std::string s_speech_text, s_read_screen, s_speech_request;

struct QuickModel { std::string id, name, default_effort; std::vector<std::string> efforts; };
std::vector<QuickModel> s_models;
size_t s_model_index = 0, s_effort_index = 0;
std::deque<std::string> s_control_queue;
board_button_t s_page_click = BOARD_BUTTON_NONE;

const char *page_name(Page page) {
    switch (page) {
    case Page::OVERVIEW: return "OVERVIEW";
    case Page::CPU: return "CPU";
    case Page::MEMORY: return "MEMORY";
    case Page::DISK: return "DISK";
    case Page::NETWORK: return "NETWORK";
    case Page::PI: return "PI";
    default: return "UNKNOWN";
    }
}
const char *modal_name(InputModal modal) {
    switch (modal) {
    case InputModal::NONE: return "NONE";
    case InputModal::VOICE: return "VOICE";
    case InputModal::SETTINGS: return "SETTINGS";
    case InputModal::ERROR: return "ERROR";
    }
    return "UNKNOWN";
}
const char *gesture_name(Gesture gesture) {
    switch (gesture) {
    case Gesture::CLICK: return "CLICK";
    case Gesture::LONG: return "LONG";
    case Gesture::LONG_RELEASE: return "LONG_RELEASE";
    case Gesture::DOUBLE_CLICK: return "DOUBLE_CLICK";
    default: return "NONE";
    }
}
const char *settings_page_name(SpeechMenu::Page page) {
    switch (page) {
    case SpeechMenu::Page::ROOT: return "ROOT";
    case SpeechMenu::Page::SPEECH: return "SPEECH";
    case SpeechMenu::Page::MODE: return "MODE";
    }
    return "UNKNOWN";
}
void log_modal_transition(InputModal before, const char *reason) {
    if (before != s_input_modal)
        ESP_LOGI(TAG, "[UI] modal %s -> %s reason=%s ui_epoch=%llu",
                 modal_name(before), modal_name(s_input_modal), reason,
                 static_cast<unsigned long long>(s_ui_epoch));
}
void log_stable_edge(bool a, bool before, const ButtonGesture &button) {
    if (before != button.down())
        ESP_LOGI(TAG, "[INPUT] %s %s event_epoch=%llu current_epoch=%llu ui_epoch=%llu page=%s modal=%s settings_phase=%s",
                 a ? "A" : "B", button.down() ? "DOWN" : "UP",
                 static_cast<unsigned long long>(button.epoch),
                 static_cast<unsigned long long>(s_ui_epoch+s_approval.generation),
                 static_cast<unsigned long long>(s_ui_epoch), page_name(s_page),
                 modal_name(s_input_modal), s_settings_phase.c_str());
}
void draw_status(const char *reason="ui_event");
bool page_overlay_visible() {
    return s_message_visible || s_approval.modal() || s_input_modal!=InputModal::NONE || !s_speech.id.empty();
}

template<size_t N> bool stored_text_equal(const char (&stored)[N], const char *incoming) {
    return std::strncmp(stored,incoming?incoming:"",N-1)==0;
}

bool rendered_tail_equal(const char *before, const char *after) {
    before=before?before:"";after=after?after:"";
    const size_t before_length=std::strlen(before), after_length=std::strlen(after);
    const size_t before_start=utf8_boundary(before,before_length,before_length>PI_RENDER_SOURCE_BYTES?before_length-PI_RENDER_SOURCE_BYTES:0);
    const size_t after_start=utf8_boundary(after,after_length,after_length>PI_RENDER_SOURCE_BYTES?after_length-PI_RENDER_SOURCE_BYTES:0);
    return std::strcmp(before+before_start,after+after_start)==0;
}

bool pi_snapshot_changes_view(const board_pi_snapshot_t &next) {
    const auto *session=next.session_id?next.session_id:"unknown";
    const bool old_tool=std::strcmp(s_pi.activity_type,"tool")==0;
    const bool new_tool=next.activity_type && std::strcmp(next.activity_type,"tool")==0;
    return !stored_text_equal(s_pi.agent,next.agent?next.agent:"pi") ||
           !stored_text_equal(s_pi.model,next.model) || !stored_text_equal(s_pi.effort,next.effort) ||
           !stored_agent_text_equal(s_pi.name,next.name?next.name:session) ||
           !stored_text_equal(s_pi.state,next.state?next.state:"OFFLINE") ||
           s_pi.has_final_text!=(next.final_text!=nullptr) || !stored_agent_text_equal(s_pi.final_text,next.final_text) ||
           s_pi.elapsed_seconds!=next.elapsed_seconds || old_tool!=new_tool ||
           (new_tool?!stored_text_equal(s_pi.latest_activity,next.latest_activity):
                     !rendered_tail_equal(s_pi_tail,next.recent_text));
}
std::string js(cJSON *root, const char *key) {
    const auto *item = cJSON_GetObjectItemCaseSensitive(root, key);
    return cJSON_IsString(item) ? item->valuestring : "";
}
std::string new_identity(uint64_t *serial = nullptr) {
    const uint64_t n = (static_cast<uint64_t>(esp_random()) << 32) | esp_random();
    if (serial) *serial = n;
    char id[17]; snprintf(id, sizeof(id), "%016llx", static_cast<unsigned long long>(n)); return id;
}
std::string selected_target() { return s_pi.selected ? std::string("agent:") + s_pi.agent + ":" + s_pi.session_id : "server"; }
void queue_control(cJSON *root) {
    const auto type=js(root,"type");
    ESP_LOGI(TAG,"[CONTROL] queue type=%s depth=%u",type.c_str(),static_cast<unsigned>(s_control_queue.size()));
    char *wire = cJSON_PrintUnformatted(root);
    if (wire && s_control_queue.size() < 8) s_control_queue.emplace_back(wire);
    else { const auto before=s_input_modal; audio_backend_record_stop(true); s_input_modal=InputModal::ERROR; s_modal_error="Control queue full"; ++s_ui_epoch; log_modal_transition(before,"control-queue-full"); }
    cJSON_free(wire); cJSON_Delete(root);
}
void stop_speech(bool notify=true) {
    if (!s_speech.id.empty())
        ESP_LOGI(TAG,"[SPEECH] stop id=%s chunks=%u ended=%d notify=%d",s_speech.id.c_str(),
                 s_speech.sequence,s_speech.ended,notify);
    audio_backend_speech_stop();
    if (notify) {
        auto *root=cJSON_CreateObject();cJSON_AddStringToObject(root,"type","speech_stop");
        // Missing ID cancels even an in-flight synthesis that has not started playing.
        queue_control(root);
    }
    s_speech.stop();s_speech_text.clear();
}
void speech_command(const char *type) {
    auto *root=cJSON_CreateObject(); cJSON_AddStringToObject(root,"type",type);
    s_speech_request=new_identity();
    cJSON_AddStringToObject(root,"request_id",s_speech_request.c_str());
    cJSON_AddStringToObject(root,"target",selected_target().c_str());
    if (std::strcmp(type,"speech_settings_set")==0) cJSON_AddStringToObject(root,"mode",SpeechMenu::mode_name(s_speech_menu.mode));
    if (std::strcmp(type,"speech_request")==0) {
        cJSON_AddStringToObject(root,"source","screen");cJSON_AddStringToObject(root,"text",s_read_screen.c_str());
    }
    queue_control(root);
}
[[maybe_unused]] std::string read_screen_text() {
    char text[512];
    if (s_page==Page::PI) {
        if (!s_pi.selected) return "No agent session selected.";
        std::string result=std::string(s_pi.agent)+". "+s_pi.state+". "+s_pi.name+". ";
        const auto view=agent_display_text(s_pi.state,s_pi.has_final_text?s_pi.final_text:nullptr,
            s_pi.activity_type,s_pi.latest_activity,s_pi_tail);
        const auto *previous_font=s_display.getFont();
        s_display.setFont(&s_transcript_font);
        const auto lines=wrap_activity_text(view.text,304,!view.terminal);
        s_display.setFont(previous_font);
        const size_t count=view.tool?6:8;
        const size_t first=!view.terminal && lines.size()>count?lines.size()-count:0;
        for (size_t i=first;i<std::min(first+count,lines.size());++i) result+=lines[i]+" ";
        if (result.size()>1200) {size_t n=1200;while (n && (static_cast<unsigned char>(result[n])&0xc0)==0x80)--n;result.resize(n);}
        return result;
    }
    switch (s_page) {
    case Page::CPU: snprintf(text,sizeof(text),"CPU %.0f percent. Load %.2f, %.2f.",s_server.cpu_percent,s_server.load_1m,s_server.load_5m);break;
    case Page::MEMORY: snprintf(text,sizeof(text),"Memory %.0f percent. Swap %.0f percent.",s_server.memory_percent,s_server.swap_percent);break;
    case Page::DISK: snprintf(text,sizeof(text),"Disk %.0f percent.",s_server.disk_percent);break;
    case Page::NETWORK: snprintf(text,sizeof(text),"Network receive %.0f bytes per second. Send %.0f bytes per second.",s_server.network_rx_bps,s_server.network_tx_bps);break;
    default: snprintf(text,sizeof(text),"CPU %.0f percent. Memory %.0f percent. Disk %.0f percent. Network receive %.0f bytes per second. Send %.0f bytes per second.",s_server.cpu_percent,s_server.memory_percent,s_server.disk_percent,s_server.network_rx_bps,s_server.network_tx_bps);break;
    }
    return text;
}
void voice_command(const char *type, const char *decision = nullptr) {
    if (s_voice_id.empty()) return;
    auto *root=cJSON_CreateObject(); cJSON_AddStringToObject(root,"type",type);
    cJSON_AddStringToObject(root,"voice_id",s_voice_id.c_str());
    if (decision) cJSON_AddStringToObject(root,"decision",decision);
    queue_control(root);
}
void settings_command(const char *type) {
    auto *root=cJSON_CreateObject(); cJSON_AddStringToObject(root,"type",type);
    cJSON_AddStringToObject(root,"request_id",s_settings_id.c_str());
    cJSON_AddStringToObject(root,"target",s_settings_target.c_str());
    cJSON_AddNumberToObject(root,"expected_target_generation",s_nav.generation);
    if(s_permission_settings)cJSON_AddBoolToObject(root,"include_permissions",true);
    if (std::strcmp(type,"settings_apply")==0 && !s_models.empty()) {
        const auto &m=s_models[s_model_index]; auto *value=cJSON_AddObjectToObject(root,"settings");
        cJSON_AddStringToObject(value,"model",m.id.c_str());
        if (!m.efforts.empty()) cJSON_AddStringToObject(value,"effort",m.efforts[s_effort_index].c_str());
    }
    queue_control(root);
}
void cancel_input(bool notify, const char *reason) {
    const auto before=s_input_modal;
    if (before!=InputModal::NONE) {
        ESP_LOGI(TAG, "[UI] cancel modal=%s reason=%s notify=%d voice_id=%s",
                 modal_name(before), reason, notify, s_voice_id.c_str());
        if (before==InputModal::VOICE) ESP_LOGI(TAG, "[VOICE] cancelled reason=%s", reason);
    }
    audio_backend_record_stop(true);
    if (notify && s_input_modal==InputModal::VOICE) voice_command("voice_cancel");
    if (notify && s_input_modal==InputModal::SETTINGS) settings_command("settings_cancel");
    if (s_input_modal==InputModal::SETTINGS && s_ui.menu==wearable::Menu::Edit &&
        s_ui.field==wearable::Field::Brightness)
        s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));
    s_input_modal=InputModal::NONE; s_ui.close_settings(); s_transcript.clear(); s_voice_id.clear(); s_settings_id.clear();
    s_feedback_until=0; s_speech_direct=false;
    // Leaving any modal also leaves the History browser; a late page is dropped.
    s_history_view=wearable::HistoryView::Closed;
    s_history_request.clear();
    s_history_loading=false;
    ++s_ui_epoch;
    log_modal_transition(before,reason);
}
using NavPage=wearable::AgentNavigator::Page;
std::array<wearable::BoundedText<33>,16> s_creation_efforts{};
uint8_t s_creation_effort_count=0;
bool s_existing_permissions=false;
cJSON* navigator_command(const char* type,bool new_request=true) {
    auto* root=cJSON_CreateObject();cJSON_AddStringToObject(root,"type",type);
    if(new_request)s_nav.request.set(new_identity().c_str());
    cJSON_AddStringToObject(root,"request_id",s_nav.request.c_str());
    cJSON_AddNumberToObject(root,"expected_target_generation",s_nav.generation);
    return root;
}
void navigator_busy(NavPage loading) {s_nav.loading=loading;s_nav.page=NavPage::Busy;s_nav.deadline=esp_timer_get_time()/1000+8000;++s_ui_epoch;}
void navigator_catalog(bool projects,bool next=false) {
    auto* root=navigator_command("catalog_get");
    cJSON_AddStringToObject(root,"kind",projects?"projects":"sessions");
    if(!projects)cJSON_AddStringToObject(root,"project_id",s_nav.project.c_str());
    if(next) {cJSON_AddStringToObject(root,"snapshot",s_nav.snapshot.c_str());cJSON_AddNumberToObject(root,"cursor",s_nav.next_cursor);}
    s_nav.clear_page();navigator_busy(projects?NavPage::Projects:NavPage::Sessions);queue_control(root);
}
void openAgentSessionPicker() {
    s_settings_phase="NAV";s_nav.operation.set("");s_nav.native_request.set("");
    navigator_catalog(true);
}
void navigator_models(bool next=false) {
    auto* root=navigator_command("creation_get");cJSON_AddStringToObject(root,"project_id",s_nav.project.c_str());cJSON_AddStringToObject(root,"agent",s_nav.agent.c_str());
    cJSON_AddNumberToObject(root,"cursor",next?s_nav.next_cursor:0);
    if(next)cJSON_AddStringToObject(root,"snapshot",s_nav.snapshot.c_str());
    s_nav.clear_page();navigator_busy(NavPage::Model);queue_control(root);
}
void navigator_efforts(unsigned cursor=0) {
    s_nav.clear_page();s_nav.page=NavPage::Effort;
    for(unsigned i=cursor;i<s_creation_effort_count && s_nav.count<4;++i) {
        auto& row=s_nav.items[s_nav.count++];row.id.set(s_creation_efforts[i].c_str());row.name.set(s_creation_efforts[i].c_str());
    }
    s_nav.next_cursor=cursor+s_nav.count<s_creation_effort_count?cursor+s_nav.count:-1;
}
void navigator_permissions(bool existing) {
    s_existing_permissions=existing;s_nav.clear_page();s_nav.page=NavPage::Permissions;
    if(!existing) {
        s_nav.count=2;s_nav.items[0].id.set("user");s_nav.items[0].name.set("Ask for approval");
        s_nav.items[1].id.set("auto_review");s_nav.items[1].name.set("Approve for me");
        if(!std::strcmp(s_nav.permission.c_str(),"auto_review"))s_nav.index=1;
    }
}
void load_permissions(cJSON* root) {
    s_settings_phase="NAV";navigator_permissions(true);
    auto* permissions=cJSON_GetObjectItemCaseSensitive(root,"permissions");
    auto* item=static_cast<cJSON*>(nullptr);
    cJSON_ArrayForEach(item,cJSON_GetObjectItemCaseSensitive(permissions,"available_modes")) {
        if(s_nav.count==4)break;
        s_nav.items[s_nav.count].id.set(js(item,"id").c_str());s_nav.items[s_nav.count].name.set(js(item,"name").c_str());++s_nav.count;
    }
    const auto current=js(permissions,"current");
    for(unsigned i=0;i<s_nav.count;++i)if(current==s_nav.items[i].id.c_str())s_nav.index=i;
    s_nav.status.set(s_nav.count?current.c_str():"Native / read-only");
    s_models.clear();
}
void native_approval_response(const ApprovalAction& action) {
    auto* root=cJSON_CreateObject();cJSON_AddStringToObject(root,"type","approval_response");cJSON_AddStringToObject(root,"approval_id",action.id.c_str());cJSON_AddStringToObject(root,"decision",action.decision.c_str());queue_control(root);
}
void navigator_voice() {
    auto* context=cJSON_CreateObject();
    if(s_nav.page==NavPage::NativeInput) {
        cJSON_AddStringToObject(context,"kind","user_input");cJSON_AddStringToObject(context,"request_id",s_nav.native_request.c_str());cJSON_AddStringToObject(context,"question_id",s_nav.native_question.c_str());
    } else {
        if(s_nav.operation.empty())s_nav.operation.set(new_identity().c_str());
        cJSON_AddStringToObject(context,"kind","new_session");cJSON_AddStringToObject(context,"operation_id",s_nav.operation.c_str());cJSON_AddStringToObject(context,"project_id",s_nav.project.c_str());cJSON_AddStringToObject(context,"agent",s_nav.agent.c_str());cJSON_AddNumberToObject(context,"expected_target_generation",s_nav.generation);
        auto* settings=cJSON_AddObjectToObject(context,"settings");cJSON_AddStringToObject(settings,"model",s_nav.model.c_str());
        if(!s_nav.effort.empty())cJSON_AddStringToObject(settings,"effort",s_nav.effort.c_str());
        if(!s_nav.permission.empty())cJSON_AddStringToObject(settings,"permissions",s_nav.permission.c_str());
    }
    auto* wire=cJSON_PrintUnformatted(context);s_voice_context=wire?wire:"";cJSON_free(wire);cJSON_Delete(context);
    s_voice_id=new_identity(&s_voice_serial);s_voice_started=0;s_voice_send_mode=s_ui.preferences.voice_send_mode;
    if(s_cloud_connected && audio_backend_record_start(s_voice_serial)) {s_input_modal=InputModal::VOICE;s_voice_phase="RECORDING";}
    else {s_input_modal=InputModal::ERROR;s_modal_error="Microphone / network unavailable";}
    ++s_ui_epoch;
}
// True while the Navigator is the gesture owner. Kept identical to the guard
// navigator_gesture() itself uses, so ownership and delivery cannot diverge.
bool navigator_owns_gesture() {
    return s_input_modal==InputModal::SETTINGS && s_settings_phase=="NAV";
}
bool navigator_gesture(bool a,Gesture g,uint64_t /*now*/) {
    if(!navigator_owns_gesture())return false;
    if(g==Gesture::LONG_RELEASE)return true;
    if(g==Gesture::LONG) {
        if(!a) {cancel_input(true,"navigator-close");return true;}
        if(s_nav.busy())return true;
        if((s_nav.page==NavPage::NativeInput && s_nav.free_text && !s_nav.secret) || s_nav.page==NavPage::FirstMessage) {navigator_voice();return true;}
        switch(s_nav.page) {
        case NavPage::Sessions:navigator_catalog(true);break;
        case NavPage::Agent:navigator_catalog(false);break;
        case NavPage::Model:s_nav.page=NavPage::Agent;s_nav.clear_page();s_nav.count=2;s_nav.items[0].name.set("Pi");s_nav.items[1].name.set("Codex");break;
        case NavPage::Effort:navigator_models();break;
        case NavPage::FirstMessage:
            if(!std::strcmp(s_nav.agent.c_str(),"codex"))navigator_permissions(false);else navigator_models();
            break;
        case NavPage::Permissions:
            if(!s_existing_permissions) {if(s_creation_effort_count)navigator_efforts();else navigator_models();break;}
            [[fallthrough]];
        default:s_settings_phase="MENU";s_ui.menu=wearable::Menu::Agent;s_ui.index=0;s_nav.page=NavPage::Closed;break;
        }
        ++s_ui_epoch;return true;
    }
    if(g!=Gesture::CLICK || s_nav.busy())return true;
    if(s_nav.page==NavPage::NativeInput) {
        if(s_nav.secret)return true;
        const unsigned choices=s_nav.option_count+(s_nav.free_text?1:0);
        if(choices==2 && !s_nav.free_text)s_nav.index=a?0:1;
        else if(a) {s_nav.next();++s_ui_epoch;return true;}
        if(s_nav.index<s_nav.option_count) {
            auto* root=cJSON_CreateObject();cJSON_AddStringToObject(root,"type","user_input_response");cJSON_AddStringToObject(root,"request_id",s_nav.native_request.c_str());cJSON_AddStringToObject(root,"question_id",s_nav.native_question.c_str());cJSON_AddNumberToObject(root,"choice_index",s_nav.index);queue_control(root);navigator_busy(NavPage::NativeInput);
        } // Other / free text waits for Hold A; no invented blank answer.
        return true;
    }
    if(a) {s_nav.next();++s_ui_epoch;return true;}
    if(s_nav.page==NavPage::FirstMessage || s_nav.page==NavPage::Error )return true;
    if(s_nav.index==s_nav.count && s_nav.next_cursor>=0) {
        if(s_nav.page==NavPage::Effort)navigator_efforts(s_nav.next_cursor);
        else if(s_nav.page==NavPage::Model)navigator_models(true);
        else navigator_catalog(s_nav.page==NavPage::Projects,true);
        return true;
    }
    if(s_nav.page==NavPage::Sessions && s_nav.index==s_nav.count+(s_nav.next_cursor>=0?1:0)) {
        s_nav.page=NavPage::Agent;s_nav.clear_page();s_nav.count=2;s_nav.items[0].name.set("Pi");s_nav.items[1].name.set("Codex");s_nav.permission.set("");s_nav.effort.set("");s_nav.operation.set("");s_existing_permissions=false;return true;
    }
    if(s_nav.index>=s_nav.count)return true;
    const auto item=s_nav.items[s_nav.index];
    switch(s_nav.page) {
    case NavPage::Projects:s_nav.project.set(item.project.c_str());navigator_catalog(false);break;
    case NavPage::Sessions:{
        auto* root=navigator_command("target_select");cJSON_AddStringToObject(root,"project_id",item.project.c_str());cJSON_AddStringToObject(root,"agent",item.agent.c_str());cJSON_AddStringToObject(root,"session_id",item.session.c_str());navigator_busy(NavPage::Sessions);queue_control(root);break;
    }
    case NavPage::Agent:s_nav.agent.set(s_nav.index?"codex":"pi");navigator_models();break;
    case NavPage::Model:
        s_nav.model.set(item.id.c_str());s_creation_efforts=item.efforts;s_creation_effort_count=item.effort_count;
        if(item.effort_count) {
            navigator_efforts();
        } else if(!std::strcmp(s_nav.agent.c_str(),"codex"))navigator_permissions(false);
        else {s_nav.page=NavPage::FirstMessage;s_nav.status.set("Hold A: first message");}
        break;
    case NavPage::Effort:
        s_nav.effort.set(item.id.c_str());
        if(!std::strcmp(s_nav.agent.c_str(),"codex"))navigator_permissions(false);
        else {s_nav.page=NavPage::FirstMessage;s_nav.status.set("Hold A: first message");}
        break;
    case NavPage::Permissions:
        s_nav.permission.set(item.id.c_str());
        if(s_existing_permissions) {
            auto* root=cJSON_CreateObject();cJSON_AddStringToObject(root,"type","settings_apply");cJSON_AddStringToObject(root,"request_id",s_settings_id.c_str());cJSON_AddStringToObject(root,"target",s_settings_target.c_str());cJSON_AddNumberToObject(root,"expected_target_generation",s_nav.generation);auto* settings=cJSON_AddObjectToObject(root,"settings");cJSON_AddStringToObject(settings,"permissions",item.id.c_str());queue_control(root);navigator_busy(NavPage::Permissions);
        } else {s_nav.page=NavPage::FirstMessage;s_nav.status.set("Hold A: first message");}
        break;
    default:break;
    }
    ++s_ui_epoch;return true;
}
bool navigator_message(cJSON* root,const std::string& type) {
    auto number=[&](const char* key)->double {auto* v=cJSON_GetObjectItemCaseSensitive(root,key);return cJSON_IsNumber(v)?v->valuedouble:-1;};
    if(type=="hello_ack") {
        s_nav.catalog=s_nav.target_select=s_nav.permissions=s_nav.native=s_nav.create=s_nav.history=false;
        cJSON* cap=nullptr;cJSON_ArrayForEach(cap,cJSON_GetObjectItemCaseSensitive(root,"capabilities")) {
            if(!cJSON_IsString(cap))continue;
            const std::string name=cap->valuestring;
            if(name=="catalog")s_nav.catalog=true;
            if(name=="target-select")s_nav.target_select=true;
            if(name=="permissions")s_nav.permissions=true;
            if(name=="native-interaction")s_nav.native=true;
            if(name=="session-create")s_nav.create=true;
            if(name=="history")s_nav.history=true;
        }
        s_nav.generation=number("target_generation")>=0?number("target_generation"):0;
        s_nav.ack_pending=false;
        // History follows the selected Agent's capability.
        s_ui.agent_capabilities(s_nav.catalog && s_nav.target_select,!std::strcmp(s_pi.agent,"codex"),s_nav.permissions && !std::strcmp(s_pi.agent,"codex"),s_nav.history);
        if(!s_nav.operation.empty()) {auto* query=navigator_command("session_operation_get");cJSON_AddStringToObject(query,"operation_id",s_nav.operation.c_str());queue_control(query);}
        return true;
    }
    if(type=="approval_request") {
        wearable::BoundedText<641> labels;
        ApprovalView view{js(root,"approval_id"),js(root,"agent"),js(root,"title"),js(root,"detail"),0};
        cJSON* item=nullptr;cJSON_ArrayForEach(item,cJSON_GetObjectItemCaseSensitive(root,"choices"))if(cJSON_IsString(item) && view.choice_count<4)view.choices[view.choice_count++]=item->valuestring;
        if(s_approval.receive(view,esp_timer_get_time()/1000)) {stop_speech();cancel_input(true,"native-approval");s_nav.native_request.set("");s_page_click=BOARD_BUTTON_NONE;}
        return true;
    }
    if(type=="user_input_cancelled") {
        if(js(root,"request_id")==s_nav.native_request.c_str() && js(root,"question_id")==s_nav.native_question.c_str()) {
            s_nav.native_request.set("");
            if(s_input_modal==InputModal::VOICE && s_voice_phase=="SENDING")return true;
            cancel_input(true,"native-request-resolved");s_nav.page=NavPage::Closed;
        }
        return true;
    }
    if(type=="user_input_request" && s_nav.native) {
        cancel_input(true,"native-user-input");s_nav.clear_page();s_nav.page=NavPage::NativeInput;s_settings_phase="NAV";s_input_modal=InputModal::SETTINGS;
        s_nav.native_request.set(js(root,"request_id").c_str());s_nav.native_question.set(js(root,"question_id").c_str());s_nav.status.set(js(root,"question").c_str());
        s_nav.secret=cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"secret"));s_nav.free_text=cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"free_text"));
        cJSON* item=nullptr;cJSON_ArrayForEach(item,cJSON_GetObjectItemCaseSensitive(root,"options"))if(cJSON_IsString(item) && s_nav.option_count<32)s_nav.options[s_nav.option_count++].set(item->valuestring);
        ++s_ui_epoch;return true;
    }
    if(type=="target_selected") {
        const auto rid=js(root,"request_id");
        if(!rid.empty() && number("target_generation")>=s_nav.generation &&
            (rid==s_nav.request.c_str() || rid==s_nav.operation.c_str())) {
            s_nav.ack_generation=number("target_generation");s_nav.ack_pending=true;
        }
        return true;
    }
    if(type=="session_operation") {
        if(js(root,"operation_id")==s_nav.operation.c_str()) {
            const auto state=js(root,"state");s_nav.status.set(state=="DONE"?"Session started":state=="NOT_FOUND"?"No creation submitted":state=="FAILED"?"Creation failed":"Creation outcome unknown\nUse desktop to inspect");
            if(state=="DONE" || state=="FAILED" || state=="NOT_FOUND")s_nav.operation.set("");
            s_nav.page=NavPage::Error;
        }return true;
    }
    if(type=="navigator_error" && js(root,"request_id")==s_nav.request.c_str()) {s_nav.page=NavPage::Error;s_nav.status.set(js(root,"message").c_str());return true;}
    if((type=="catalog_page" || type=="creation_options") && js(root,"request_id")==s_nav.request.c_str() && s_input_modal==InputModal::SETTINGS && s_settings_phase=="NAV") {
        s_nav.page=s_nav.loading;s_nav.clear_page();s_nav.status.set(js(root,"status").c_str());s_nav.snapshot.set(js(root,"snapshot").c_str());s_nav.next_cursor=number("next_cursor");
        cJSON* item=nullptr;cJSON_ArrayForEach(item,cJSON_GetObjectItemCaseSensitive(root,"items")) {
            if(s_nav.count==4)break;
            auto& row=s_nav.items[s_nav.count++];
            row.project.set(js(item,"project_id").c_str());row.agent.set(js(item,"agent").c_str());row.session.set(js(item,"session_id").c_str());row.id.set(js(item,"id").c_str());row.name.set(js(item,"name").c_str());
            cJSON* effort=nullptr;cJSON_ArrayForEach(effort,cJSON_GetObjectItemCaseSensitive(item,"available_efforts"))if(cJSON_IsString(effort)&&row.effort_count<16)row.efforts[row.effort_count++].set(effort->valuestring);
        }
        ++s_ui_epoch;return true;
    }
    return false;
}
void draw_navigator() {
    draw_header(s_nav.page==NavPage::NativeInput?"AGENT INPUT":"AGENT SESSION");
    std::string body=s_nav.status.c_str(),footer="A NEXT   B SELECT";
    if(s_nav.page==NavPage::Busy) {body="Loading...";footer="Busy   Hold B Close";}
    else if(s_nav.page==NavPage::Agent)body="Choose Agent";
    else if(s_nav.page==NavPage::Model)body="Choose Model";
    else if(s_nav.page==NavPage::Effort)body="Choose Effort";
    else if(s_nav.page==NavPage::Permissions)body="Permissions\n"+body;
    if(s_nav.page==NavPage::NativeInput) {
        auto question_lines=wrap_activity_text(body.c_str(),304);body.clear();
        const size_t visible=std::min<size_t>(question_lines.size(),3);
        for(size_t i=0;i<visible;++i) {if(i)body+="\n";body+=question_lines[i];}
        if(question_lines.size()>visible)body+="...";
        if(s_nav.secret)footer="Answer on desktop";
        else if(s_nav.option_count==2 && !s_nav.free_text) {body+="\nA "+std::string(s_nav.options[0].c_str())+"\nB "+s_nav.options[1].c_str();footer="A / B ANSWER";}
        else {body+="\n\n"+(s_nav.index<s_nav.option_count?std::string(s_nav.options[s_nav.index].c_str()):"Other / free text");footer=s_nav.free_text?"A NEXT   B SELECT   Hold A Voice":"A NEXT   B SELECT";}
    } else if(s_nav.index<s_nav.count) {
        const auto& row=s_nav.items[s_nav.index];
        if(s_nav.page==NavPage::Sessions)body+="\n\n"+std::string(row.agent.c_str())+"  "+row.name.c_str()+"\n"+std::string(row.session.c_str()).substr(std::strlen(row.session.c_str())>8?std::strlen(row.session.c_str())-8:0);
        else body+="\n\n"+std::string(row.name.c_str());
    } else if(s_nav.page==NavPage::Sessions && s_nav.create && s_nav.index==s_nav.count+(s_nav.next_cursor>=0?1:0))body+="\n\n+ New Session";
    else if(s_nav.next_cursor>=0)body+="\n\nNext page";
    if(s_nav.page==NavPage::FirstMessage)footer="Hold A Voice   Hold B Close";
    if(s_nav.page==NavPage::Error)footer="Hold A Back   Hold B Close";
    s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_left);auto lines=wrap_activity_text(body.c_str(),304);const int height=s_display.fontHeight()+5;
    for(size_t i=0;i<std::min<size_t>(lines.size(),(360-120)/height);++i)s_display.drawString(lines[i].c_str(),82,120+i*height);
    s_fonts.apply(s_display,wearable::TypeRole::Footer,s_ui.preferences.text_size,wearable::palette(s_ui.preferences.theme).accent,TFT_BLACK);s_display.setTextDatum(textdatum_t::middle_center);s_display.drawString(footer.c_str(),234,386);
}

void pin_message_voice_context() {
    if(!s_nav.target_select)return;
    auto* root=cJSON_CreateObject();cJSON_AddStringToObject(root,"kind","message");cJSON_AddStringToObject(root,"target",selected_target().c_str());cJSON_AddNumberToObject(root,"expected_target_generation",s_nav.generation);
    auto* wire=cJSON_PrintUnformatted(root);s_voice_context=wire?wire:"";cJSON_free(wire);cJSON_Delete(root);
}
// Double A: enter the Speech Mode selector directly (OFF/MANUAL/AUTO_REPLY/
// AUTO_SUMMARY). A NEXT cycles, B APPLY saves the existing speech_settings_set
// preference. Only the preference is touched; playback stays frozen.
void open_speech_mode() {
    if(!s_cloud_connected || !s_pi.selected) {
        s_input_modal=InputModal::ERROR;
        s_modal_error=!s_pi.selected?"NO AGENT SELECTED":"OFFLINE";
        ++s_ui_epoch;
        return;
    }
    s_speech_menu.open_mode();
    s_speech_direct=true;
    s_settings_phase="SPEECH";
    s_ui.open_settings();
    s_input_modal=InputModal::SETTINGS;
    ++s_ui_epoch;
}
// Double B: the existing Agent Session Picker entry, identical to
// Settings -> Agent -> Session. No second quick picker is implemented.
void open_double_session_picker() {
    if(!s_nav.catalog || !s_nav.target_select || !s_cloud_connected) {
        s_input_modal=InputModal::ERROR;
        s_modal_error=!s_cloud_connected?"OFFLINE":"SESSION PICKER UNAVAILABLE";
        ++s_ui_epoch;
        return;
    }
    s_speech_direct=false;
    s_ui.open_settings();
    s_input_modal=InputModal::SETTINGS;
    // Exactly the Settings -> Agent -> Session entry: same Project Picker, same
    // Session list, same target CAS and authoritative snapshot.
    openAgentSessionPicker();
    ++s_ui_epoch;
}
// ---- Bounded Agent History ------------------------------------------------
// Read-only. Requests are bound to the connection, target, target generation,
// agent and session_id; any change discards an in-flight or late page.
void history_request_page(bool older) {
    if (!s_nav.history) {
        s_history_error = "History unavailable";
        return;
    }
    auto* root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "type", "history_get");
    s_history_request = new_identity();
    cJSON_AddStringToObject(root, "request_id", s_history_request.c_str());
    cJSON_AddNumberToObject(root, "expected_target_generation", s_nav.generation);
    cJSON_AddStringToObject(root, "target", selected_target().c_str());
    cJSON_AddStringToObject(root, "agent", s_pi.agent);
    cJSON_AddStringToObject(root, "session_id", s_pi.session_id);
    if (older && !s_history_cache.older_cursor().empty())
        cJSON_AddStringToObject(root, "cursor", s_history_cache.older_cursor().c_str());
    s_history_loading = true;
    s_history_error.clear();
    queue_control(root);
}
// Layout for the History body. The wrap and the visible row count must be the
// same numbers used for rendering and for the screen count, so a long message is
// traversed exactly once per screen in both directions.
struct HistoryLayout {
    static constexpr int kTop = 160, kBottom = 360;
    std::vector<std::string> lines;
    size_t rows = 1;
    size_t screens = 1;
};
HistoryLayout history_layout(const std::string& body) {
    HistoryLayout layout;
    const auto* previous_font = s_display.getFont();
    s_fonts.apply(s_display, wearable::TypeRole::AgentBody, s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary, TFT_BLACK);
    const int line_height = s_display.fontHeight() + 5;
    layout.rows = std::max<size_t>(1, size_t(HistoryLayout::kBottom - HistoryLayout::kTop) / size_t(line_height));
    layout.lines = wrap_activity_text(body.c_str(), 304);
    layout.screens = std::max<size_t>(1, (layout.lines.size() + layout.rows - 1) / layout.rows);
    s_display.setFont(previous_font);
    return layout;
}
void history_rebuild_screen() {
    std::vector<std::string> roles, agents, bodies;
    std::vector<size_t> screens;
    if (!s_history_cache.empty()) {
        for (const auto& message : s_history_cache.current().messages) {
            roles.emplace_back(message.role == wearable::HistoryRole::User ? "USER" : "ASSISTANT");
            agents.push_back(message.agent);
            bodies.push_back(message.text);
            screens.push_back(history_layout(message.text).screens);
        }
    }
    s_history_screen.load(roles, agents, bodies, screens);
}
// Enter (or leave) the History view. It lives under SETTINGS modal ownership so
// it inherits the same epoch and stale protection as every other overlay.
void open_agent_history(bool from_settings) {
    if (!s_pi.selected) { s_input_modal = InputModal::ERROR; s_modal_error = "NO AGENT SELECTED"; ++s_ui_epoch; return; }
    if (!s_nav.history) { s_input_modal = InputModal::ERROR; s_modal_error = "HISTORY UNAVAILABLE"; ++s_ui_epoch; return; }
    if (!s_cloud_connected) { s_input_modal = InputModal::ERROR; s_modal_error = "OFFLINE"; ++s_ui_epoch; return; }
    s_history_view = wearable::HistoryView::Browser;
    s_history_cache.clear();
    s_history_screen.load({}, {}, {}, {});
    s_agent_browse.reset();
    s_speech_direct = false;
    s_ui.open_settings();
    s_input_modal = InputModal::SETTINGS;
    s_settings_phase = "HISTORY";
    (void)from_settings;
    ++s_ui_epoch;
    history_request_page(false);
}
void close_agent_history(const char* reason) {
    s_history_view = wearable::HistoryView::Closed;
    s_history_cache.clear();
    s_history_screen.load({}, {}, {}, {});
    s_history_request.clear();
    s_history_loading = false;
    s_history_error.clear();
    cancel_input(false, reason);
}
// Older = further back in time. Inside the current page it moves the text
// screen; at the page boundary it first walks the already-cached older pages
// (and only then asks the server), so a cached page is never re-requested.
void history_older() {
    if (s_history_screen.older()) { ++s_ui_epoch; return; }
    // A page the server already delivered is reachable from the local cache.
    // Crossing into it lands on its newest message, the immediate continuation
    // of the linear newest-to-oldest sequence.
    if (s_history_cache.older()) {
        history_rebuild_screen();
        ++s_ui_epoch;
        return;
    }
    // Never queue a second fetch while one page is still in flight.
    if (s_history_loading) { ++s_ui_epoch; return; }
    if (s_history_cache.at_oldest() && s_history_cache.older_cursor().empty()) {
        s_history_error = "Oldest message";
        ++s_ui_epoch;
        return;
    }
    history_request_page(true);
    ++s_ui_epoch;
}
// Newer past the newest history message returns to the Agent live view.
void history_newer() {
    if (s_history_screen.newer()) { ++s_ui_epoch; return; }
    if (s_history_cache.newer()) {
        history_rebuild_screen();
        // Cross the page boundary onto the last screen of the newer page, which
        // is the immediate successor in the linear newest-to-oldest sequence.
        while (s_history_screen.older()) {}
        ++s_ui_epoch;
        return;
    }
    close_agent_history("history-live");
}
bool history_message(cJSON* root, const std::string& type) {
    if (type != "history_page" && type != "history_error") return false;
    // Only the matching in-flight request may change the view; a late response
    // for an older target/session is ignored.
    if (js(root, "request_id") != s_history_request) {
        ESP_LOGI(TAG, "[HISTORY] ignored stale response type=%s", type.c_str());
        return true;
    }
    s_history_loading = false;
    if (type == "history_error") {
        s_history_error = js(root, "message");
        if (s_history_error.empty()) s_history_error = "History unavailable";
        if (s_history_view != wearable::HistoryView::Browser) return true;
        ++s_ui_epoch;
        return true;
    }
    const auto* generation = cJSON_GetObjectItemCaseSensitive(root, "generation");
    const uint64_t reported = cJSON_IsNumber(generation) ? uint64_t(generation->valuedouble) : ~0ULL;
    if (js(root, "target") != selected_target() || reported != s_nav.generation) {
        ESP_LOGI(TAG, "[HISTORY] ignored response reason=target-or-generation");
        return true;
    }
    wearable::HistoryPage page;
    page.valid = true;
    page.generation = s_nav.generation;
    const auto* next = cJSON_GetObjectItemCaseSensitive(root, "next_cursor");
    if (cJSON_IsString(next)) page.next_cursor = next->valuestring;
    const auto* messages = cJSON_GetObjectItemCaseSensitive(root, "messages");
    cJSON* item = nullptr;
    cJSON_ArrayForEach(item, messages) {
        if (page.messages.size() >= wearable::AgentHistoryCache::kMaxMessages) break;
        wearable::HistoryMessage message;
        message.role = js(item, "role") == "user" ? wearable::HistoryRole::User : wearable::HistoryRole::Assistant;
        message.agent = s_pi.agent;
        bool truncated = false;
        message.text = wearable::history_bounded_text(js(item, "text"), wearable::HISTORY_MAX_TEXT_BYTES, truncated);
        message.truncated = truncated;
        page.messages.push_back(message);
    }
    if (s_history_cache.empty()) s_history_cache.reset(page);
    else s_history_cache.append_older(page);
    // append_older makes the requested older page current, so the rebuild below
    // renders exactly the page the user asked for; cache content and the
    // displayed page are one state, never two that can drift.
    history_rebuild_screen();
    ESP_LOGI(TAG, "[HISTORY] page messages=%u next=%d cache_pages=%u cache_index=%u",
             unsigned(page.messages.size()), !page.next_cursor.empty(),
             unsigned(s_history_cache.size()), unsigned(s_history_cache.index()));
    ++s_ui_epoch;
    return true;
}
void draw_history() {
    draw_header(s_pi.agent[0] ? (std::string(s_pi.agent) == "codex" ? "CODEX \xc2\xb7 HISTORY" : "PI \xc2\xb7 HISTORY") : "HISTORY");
    const auto& p = wearable::palette(s_ui.preferences.theme);
    if (s_history_loading && s_history_screen.empty()) {
        s_fonts.apply(s_display, wearable::TypeRole::AgentBody, s_ui.preferences.text_size, p.primary, TFT_BLACK);
        s_display.setTextDatum(textdatum_t::middle_center);
        s_display.drawString("Loading...", 234, 220);
        s_fonts.apply(s_display, wearable::TypeRole::Footer, s_ui.preferences.text_size, p.accent, TFT_BLACK);
        s_display.drawString("Hold B Close", 234, 386);
        return;
    }
    if (s_history_screen.empty()) {
        s_fonts.apply(s_display, wearable::TypeRole::AgentBody, s_ui.preferences.text_size, p.primary, TFT_BLACK);
        s_display.setTextDatum(textdatum_t::middle_center);
        s_display.drawString(s_history_error.empty() ? "No history" : s_history_error.c_str(), 234, 220);
        s_fonts.apply(s_display, wearable::TypeRole::Footer, s_ui.preferences.text_size, p.accent, TFT_BLACK);
        s_display.drawString("Hold B Close", 234, 386);
        return;
    }
    s_fonts.apply(s_display, wearable::TypeRole::Meta, s_ui.preferences.text_size, p.secondary, TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    s_display.drawString(s_history_screen.header().c_str(), 234, 132);
    // Render only the current text screen of the current message; a long message
    // is browsed one screen at a time before the next older message appears.
    const auto layout = history_layout(s_history_screen.body());
    const size_t first = std::min(s_history_screen.page * layout.rows, layout.lines.size());
    s_fonts.apply(s_display, wearable::TypeRole::AgentBody, s_ui.preferences.text_size, p.primary, TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_left);
    const int height = s_display.fontHeight() + 5;
    for (size_t i = first; i < std::min(first + layout.rows, layout.lines.size()); ++i)
        s_display.drawString(layout.lines[i].c_str(), 82, HistoryLayout::kTop + int(i - first) * height);
    s_fonts.apply(s_display, wearable::TypeRole::Footer, s_ui.preferences.text_size, p.accent, TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    s_display.drawString(s_history_screen.position().c_str(), 234, 372);
    s_display.drawString("A NEWER   B OLDER", 234, 396);
}
void handle_gesture(bool a, Gesture g, uint64_t epoch, uint64_t now) {
    if (g==Gesture::NONE) return;
    ESP_LOGI(TAG, "[INPUT] %s %s event_epoch=%llu current_epoch=%llu ui_epoch=%llu page=%s modal=%s settings_phase=%s approval=%d",
             a?"A":"B", gesture_name(g), static_cast<unsigned long long>(epoch),
             static_cast<unsigned long long>(s_ui_epoch+s_approval.generation),
             static_cast<unsigned long long>(s_ui_epoch), page_name(s_page),
             modal_name(s_input_modal), s_settings_phase.c_str(), s_approval.modal());
    const auto before=s_input_modal;
    if (s_approval.modal()) {
        if (g==Gesture::CLICK && epoch==s_ui_epoch+s_approval.generation) {
            auto action=s_approval.release(a,s_approval.generation,now);
            if (!action.id.empty()) {if(action.decision.empty())s_approval_action=action;else native_approval_response(action);}
        }
        draw_status(); return;
    }
    // The Navigator is a business reducer: a CLICK or LONG can change the
    // page/state (and, for Native UserInput, commit a response). Both must
    // therefore be verified against the UI epoch/owner that was captured when
    // the contact started, before the Navigator sees them. A stale contact that
    // survives a page change must never act on the new owner.
    //
    // LONG_RELEASE is deliberately exempt: it is the completion half of an
    // interaction that already legitimately started (for example Voice
    // recording), so it must still be delivered even though the epoch advanced.
    // navigator_gesture() itself never acts on a LONG_RELEASE.
    if (navigator_owns_gesture()) {
        if (g==Gesture::LONG_RELEASE || epoch==s_ui_epoch+s_approval.generation) {
            if(navigator_gesture(a,g,now)) {draw_status();return;}
        } else {
            // The Navigator still owns this gesture, but the owner changed after
            // the contact began. Consume it so it cannot reach the new owner's
            // reducer; do not act on it.
            ESP_LOGI(TAG,"[NAV] ignored reason=stale-epoch");
            draw_status(); return;
        }
    }
    if (g==Gesture::DOUBLE_CLICK) {
        // Defence in depth: the state machine only reports DOUBLE_CLICK when
        // eligible, but re-check the exact ownership before acting.
        if (!shell_double_press_allowed(s_input_modal,s_approval.modal(),page_overlay_visible())) {
            ESP_LOGI(TAG,"[INPUT] ignored %s reason=double-not-eligible",a?"double-A":"double-B");
            return;
        }
        if (epoch!=s_ui_epoch+s_approval.generation) { ESP_LOGI(TAG,"[INPUT] ignored reason=stale-epoch"); return; }
        ESP_LOGI(TAG,"[SHELL] %s app=%u",a?"double-A speech-mode":"double-B session-picker",unsigned(s_ui.app));
        if (a) open_speech_mode(); else open_double_session_picker();
        if (before==InputModal::NONE) log_modal_transition(before,a?"double-A":"double-B");
        draw_status(); return;
    }
    if (g==Gesture::LONG_RELEASE) {
        if (a && s_input_modal==InputModal::VOICE && s_voice_phase=="RECORDING") {
            ESP_LOGI(TAG,"[VOICE] long-release voice_id=%s -> TRANSCRIBING",s_voice_id.c_str());
            s_voice_started=audio_backend_record_started_ms(s_voice_serial);
            const bool short_recording=voice_recording_too_short(s_voice_started,now);
            audio_backend_record_stop(short_recording);
            if(short_recording) {
                voice_command("voice_cancel");s_voice_phase="Recording too short";s_transcript.clear();s_feedback_until=now+1500;
            } else s_voice_phase="TRANSCRIBING";
            draw_status();
        }
        return;
    }
    if (epoch!=s_ui_epoch+s_approval.generation) { ESP_LOGI(TAG,"[INPUT] ignored reason=stale-epoch"); return; }
    if (s_input_modal==InputModal::VOICE) {
        if (g==Gesture::CLICK && s_voice_phase=="AWAITING_CONFIRMATION") {
            voice_command("voice_decision",a?"send":"cancel");
            if (a) { s_voice_phase="SENDING"; ++s_ui_epoch; } else cancel_input(false,"user-decision-cancel");
        } else if (!a && g==Gesture::CLICK) cancel_input(true,"user-cancel");
    } else if (s_input_modal==InputModal::ERROR) {
        if (!a) cancel_input(true,"error-close");
    } else if (s_input_modal==InputModal::SETTINGS) {
        if (!a && g==Gesture::LONG) cancel_input(true,"settings-close");
        else if(a && g==Gesture::LONG) {
            if(s_settings_phase=="LOADING" || s_settings_phase=="APPLYING" || s_settings_phase=="SAVING SPEECH") {
                ESP_LOGI(TAG,"[SETTINGS] Back blocked while %s",s_settings_phase.c_str());
            } else if(s_settings_phase=="HISTORY") {
                // Hold A leaves History back to the Agent settings menu.
                s_history_view=wearable::HistoryView::Closed;
                s_history_cache.clear();s_history_screen.load({},{},{},{});s_history_error.clear();
                s_settings_phase="MENU";s_ui.menu=wearable::Menu::Agent;s_ui.index=0;
                s_preference_feedback.clear();++s_ui_epoch;
            } else {
                if(s_settings_phase=="MENU") {
                    const bool brightness=s_ui.menu==wearable::Menu::Edit && s_ui.field==wearable::Field::Brightness;
                    const auto effect=s_ui.settings_back();
                    if(brightness) s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));
                    if(effect==wearable::UiEffect::CloseSettings) cancel_input(false,"settings-back");
                } else if(s_settings_phase=="EFFORT") s_settings_phase="MODEL";
                else if(s_settings_phase=="SPEECH" && s_speech_menu.back()==SpeechMenuAction::NONE) {
                    // MODE returns explicitly to SPEECH, discarding its draft.
                } else {
                    s_settings_phase="MENU";s_ui.menu=wearable::Menu::Agent;s_ui.index=0;
                    s_settings_id.clear();s_models.clear();
                }
                s_preference_feedback.clear();++s_ui_epoch;
            }
        }
        else if (g==Gesture::CLICK && s_settings_phase=="MENU") {
            const auto before_menu=s_ui.menu;const auto before_field=s_ui.field;
            const auto effect=s_ui.settings_click(a);
            s_preference_feedback.clear();
            if(effect==wearable::UiEffect::CloseSettings) cancel_input(false,"settings-back");
            else if(effect==wearable::UiEffect::SessionPicker) {openAgentSessionPicker();}
            else if(effect==wearable::UiEffect::AgentSettings || effect==wearable::UiEffect::EffortSettings || effect==wearable::UiEffect::PermissionSettings) {
                s_effort_settings=effect==wearable::UiEffect::EffortSettings;
                s_permission_settings=effect==wearable::UiEffect::PermissionSettings;
                if(s_pi.selected && s_cloud_connected) {
                    s_settings_id=new_identity();s_settings_target=selected_target();
                    s_settings_phase="LOADING";settings_command("settings_get");
                }
            } else if(effect==wearable::UiEffect::SpeechSettings) {
                if(s_pi.selected && s_cloud_connected) {
                    s_settings_phase="Speech Output FROZEN";
                }
            } else if(effect==wearable::UiEffect::History) {
                open_agent_history(true);
                return;
            } else if(effect==wearable::UiEffect::Apply) {
                // board_update holds the display owner lock; no ISR/network setter.
                s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));
                s_vibration_enabled=s_ui.preferences.vibration_enabled;
                const auto result=preferences_save(s_saved_preferences,s_ui.preferences);
                s_preference_feedback=result==ESP_OK?"APPLIED / SAVED":"APPLIED / NOT SAVED";
                ESP_LOGI(TAG,"[PREFS] apply brightness=%u panel=%u vibration=%u home=%u text=%u theme=%u slot=%d generation=%lu saved=%d readonly=%d",
                    s_ui.preferences.brightness,wearable::panel_value(s_ui.preferences.brightness),
                    s_ui.preferences.vibration_enabled,unsigned(s_ui.preferences.home_screen),
                    unsigned(s_ui.preferences.text_size),unsigned(s_ui.preferences.theme),s_saved_preferences.slot,
                    (unsigned long)s_saved_preferences.generation,result==ESP_OK,s_saved_preferences.read_only);
            }
            if(a && before_menu==wearable::Menu::Edit && before_field==wearable::Field::Brightness)
                s_display.setPanelBrightness(wearable::panel_value(s_ui.draft.brightness));
            ++s_ui_epoch;
        }
        else if(g==Gesture::CLICK && s_settings_phase=="HISTORY") {
            // A = Newer, B = Older. Buttons and touch both land here.
            if(a) history_newer(); else history_older();
        }
        else if(g==Gesture::CLICK && s_settings_phase=="SPEECH") {
            const auto action=s_speech_menu.click(a);
            if(action==SpeechMenuAction::SAVE_MODE) {speech_command("speech_settings_set");s_settings_phase="SAVING SPEECH";}
            else if(action==SpeechMenuAction::READ_SCREEN) {speech_command("speech_request");cancel_input(false,"read-screen");}
            ++s_ui_epoch;
        }
        else if (g==Gesture::CLICK && !s_models.empty() && (s_settings_phase=="MODEL" || s_settings_phase=="EFFORT")) {
            ESP_LOGI(TAG,"[SETTINGS] %s %s phase=%s model_index=%u effort_index=%u",
                     a?"A":"B",a?"NEXT":"SELECT",s_settings_phase.c_str(),
                     static_cast<unsigned>(s_model_index),static_cast<unsigned>(s_effort_index));
            if (a) {
                if (s_settings_phase=="MODEL") s_model_index=(s_model_index+1)%s_models.size();
                else s_effort_index=(s_effort_index+1)%s_models[s_model_index].efforts.size();
            } else if (s_settings_phase=="MODEL" && !s_models[s_model_index].efforts.empty()) {
                s_settings_phase="EFFORT"; s_effort_index=0;
                const auto &m=s_models[s_model_index];
                for (size_t i=0;i<m.efforts.size();++i) if (m.efforts[i]==m.default_effort) s_effort_index=i;
                ++s_ui_epoch;
            } else { settings_command("settings_apply"); s_settings_phase="APPLYING"; ++s_ui_epoch; }
        }
    } else if (g==Gesture::LONG) {
        stop_speech();
        if (a && (!s_pi.selected || !s_cloud_connected)) {
            s_input_modal=InputModal::ERROR; s_modal_error=s_pi.selected?"OFFLINE":"NO AGENT SELECTED"; ++s_ui_epoch;
        } else if (a) {
            s_voice_id=new_identity(&s_voice_serial); s_voice_started=0;
            s_voice_send_mode=s_ui.preferences.voice_send_mode;s_voice_context.clear();pin_message_voice_context();
            ESP_LOGI(TAG,"[VOICE] start voice_id=%s",s_voice_id.c_str());
            if (audio_backend_record_start(s_voice_serial)) { s_input_modal=InputModal::VOICE; s_voice_phase="RECORDING"; }
            else {s_input_modal=InputModal::ERROR;s_modal_error="Microphone unavailable";}
            ++s_ui_epoch;
        } else {
            s_settings_id=new_identity();s_settings_target=selected_target();s_settings_phase="MENU";s_models.clear();
            s_ui.open_settings();s_preference_feedback.clear();s_speech_direct=false;
            s_input_modal=InputModal::SETTINGS; ++s_ui_epoch;
        }
    } else if (g==Gesture::CLICK) {
        s_page_click=a?BOARD_BUTTON_A:BOARD_BUTTON_B;
        ESP_LOGI(TAG,"[INPUT] %s CLICK queued for page transition",a?"A":"B");
    }
    if (before==InputModal::NONE) log_modal_transition(before,g==Gesture::LONG?(a?"long-A":"long-B"):"click");
    draw_status();
}
void poll_shell_button(ButtonGesture &button, bool a, bool raw_down, uint64_t now) {
    const bool before=button.down();
    // Double A/B only exist on the normal top-level Shell: no modal, no
    // approval, no overlay. Every other owner keeps immediate single CLICKs.
    const bool allow_double=shell_double_press_allowed(s_input_modal,s_approval.modal(),page_overlay_visible());
    // A modal/approval/overlay that takes ownership mid-gap discards a pending
    // single click instead of replaying it later against the new UI.
    if(!allow_double && button.pending_click()) button.invalidate_pending();
    const auto gesture=button.update(raw_down,now,s_ui_epoch+s_approval.generation,
        shell_long_press_allowed(s_input_modal,s_approval.modal()),allow_double);
    log_stable_edge(a,before,button);
    handle_gesture(a,gesture,button.epoch,now);
}

// ---- Touch -> virtual A/B -------------------------------------------------
// Touch never duplicates Approval/Voice/Settings/Navigator logic. Each resolved
// gesture becomes exactly one virtual button gesture fed through the same
// handle_gesture reducer, so modal ownership, approval generation, ui epoch and
// stale protection are identical to the physical buttons.
//
// A contact captures the ownership epoch when it first becomes valid. Every
// Tap / Swipe / Long from that contact is bound to that captured epoch, exactly
// like a physical press. If Approval, Native UserInput, Voice, Settings or any
// other owner changes the epoch mid-contact, the unresolved gesture is dropped
// and never applied to the new owner.
uint64_t s_touch_contact_epoch = 0;
bool s_touch_contact_valid = false;
// Capture ownership at the first valid contact, exactly like a physical press.
void touch_note_contact_start() {
    s_touch_contact_epoch = s_ui_epoch + s_approval.generation;
    s_touch_contact_valid = true;
}

void touch_virtual_click(bool a, uint64_t now) {
    // handle_gesture drops the CLICK itself if the captured epoch is stale.
    handle_gesture(a, Gesture::CLICK, s_touch_contact_epoch, now);
}
// A long virtual press is delivered as LONG then LONG_RELEASE, mirroring one
// physical hold, so Voice and Settings receive the same two-phase sequence. The
// LONG is epoch-bound; the LONG_RELEASE is not, so it can end the owner that the
// LONG itself legitimately opened (Voice recording).
bool s_touch_virtual_long = false;
bool s_touch_virtual_long_a = false;
void touch_virtual_long_start(bool a, uint64_t now) {
    s_touch_virtual_long = true;
    s_touch_virtual_long_a = a;
    handle_gesture(a, Gesture::LONG, s_touch_contact_epoch, now);
}
void touch_virtual_long_end(uint64_t now) {
    if (!s_touch_virtual_long) return;
    s_touch_virtual_long = false;
    handle_gesture(s_touch_virtual_long_a, Gesture::LONG_RELEASE, s_ui_epoch + s_approval.generation, now);
}
// Diagnostics: record the first distinct edge/center contact so a physical test
// can confirm orientation without high-rate logging.
void touch_diagnostic(uint16_t x, uint16_t y) {
    const uint16_t cx = s_touch_transform.display_width / 2;
    const uint16_t cy = s_touch_transform.display_height / 2;
    const uint16_t margin_x = s_touch_transform.display_width / 6;
    const uint16_t margin_y = s_touch_transform.display_height / 6;
    const char* zone = nullptr;
    if (x < margin_x && !s_touch_diag_left) { zone = "left"; s_touch_diag_left = true; }
    else if (x > s_touch_transform.display_width - margin_x && !s_touch_diag_right) { zone = "right"; s_touch_diag_right = true; }
    else if (y < margin_y && !s_touch_diag_top) { zone = "top"; s_touch_diag_top = true; }
    else if (y > s_touch_transform.display_height - margin_y && !s_touch_diag_bottom) { zone = "bottom"; s_touch_diag_bottom = true; }
    else if (x > cx - margin_x && x < cx + margin_x && y > cy - margin_y && y < cy + margin_y && !s_touch_diag_center) {
        zone = "center"; s_touch_diag_center = true;
    }
    if (zone) ESP_LOGI(TAG, "[TOUCH] coord %s x=%u y=%u (display %ux%u)",
                       zone, unsigned(x), unsigned(y),
                       s_touch_transform.display_width, s_touch_transform.display_height);
}
// Return true when the Agent page handled a vertical swipe (manual browse or
// History entry), so the top-level app swipe does not also fire.
bool agent_vertical_swipe(wearable::TouchGesture gesture, uint64_t /*now*/) {
    if (s_ui.app != wearable::App::Agent || !s_pi.selected) return false;
    if (gesture == wearable::TouchGesture::SwipeDown) {
        if (s_agent_browse.older()) { ++s_ui_epoch; return true; }
        // Past the oldest local page: continue into History's newest page.
        open_agent_history(false);
        return true;
    }
    if (gesture == wearable::TouchGesture::SwipeUp) {
        if (s_history_view == wearable::HistoryView::Browser) { history_older(); return true; }
        s_agent_browse.newer();
        ++s_ui_epoch;
        return true;
    }
    return false;
}
void touch_gesture(wearable::TouchGesture gesture, uint64_t now) {
    if (gesture == wearable::TouchGesture::None) return;
    const bool top_level = s_input_modal == InputModal::NONE && !s_approval.modal() && !page_overlay_visible();
    // A contact belongs to the owner that was live when it started. If that
    // owner changed mid-contact the unresolved gesture is discarded. Tap and
    // Long are epoch-bound inside handle_gesture; the direct Shell swipes must
    // apply the same rule here.
    const bool owned = s_touch_contact_valid &&
        s_touch_contact_epoch == s_ui_epoch + s_approval.generation;
    switch (gesture) {
    case wearable::TouchGesture::Tap:
        // Right half is virtual A (next/approve/send), left half virtual B.
        touch_virtual_click(s_touch_reducer.side() == wearable::TouchSide::Right, now);
        break;
    case wearable::TouchGesture::Long:
        touch_virtual_long_start(s_touch_reducer.side() == wearable::TouchSide::Right, now);
        break;
    case wearable::TouchGesture::LongRelease:
        touch_virtual_long_end(now);
        break;
    case wearable::TouchGesture::SwipeLeft:
        // History: swipe left browses Older, mirroring B / tap-left.
        if (s_history_view == wearable::HistoryView::Browser) { if (owned) history_older(); }
        else if (owned && top_level) { s_message_visible = false; board_next_page(); }
        break;
    case wearable::TouchGesture::SwipeRight:
        // History: swipe right returns toward Newer, mirroring A / tap-right.
        if (s_history_view == wearable::HistoryView::Browser) { if (owned) history_newer(); }
        else if (owned && top_level) { s_message_visible = false; board_previous_page(); }
        break;
    case wearable::TouchGesture::SwipeUp:
    case wearable::TouchGesture::SwipeDown:
        if (!owned) break;
        if (s_history_view == wearable::HistoryView::Browser) {
            // SwipeDown browses older, SwipeUp returns toward newest.
            if (gesture == wearable::TouchGesture::SwipeDown) history_older(); else history_newer();
        } else if (top_level) agent_vertical_swipe(gesture, now);
        break;
    default:
        break;
    }
}
void poll_shell_touch(bool raw_pressed, uint64_t now) {
    // Touch Off ignores every touch gesture; the buttons stay fully usable, so
    // Settings -> Device -> Touch can always turn it back on.
    if (!s_touch_ready || !s_ui.preferences.touch_enabled) {
        if (s_touch_reducer.active()) s_touch_reducer.update(false, 0, 0, now);
        s_touch_reducer.reset();
        touch_virtual_long_end(now);
        s_touch_contact_valid = false;
        s_touch_down.store(false, std::memory_order_relaxed);
        return;
    }
    uint16_t raw_x = 0, raw_y = 0, x = 0, y = 0;
    bool pressed = raw_pressed;
    if (pressed) {
        // No periodic I2C while idle: only an asserted INT or an active contact
        // reaches this read. A stale INT with no point is treated as a release.
        if (!s_touch.read(raw_x, raw_y)) pressed = false;
        else s_touch_transform.apply(raw_x, raw_y, x, y);
    }
    s_touch_down.store(pressed, std::memory_order_relaxed);
    const auto gesture = s_touch_reducer.update(pressed, x, y, now);
    if (gesture == wearable::TouchGesture::Down) {
        // Capture ownership at the first valid contact, exactly like a physical
        // press. A later epoch change invalidates every unresolved gesture.
        touch_note_contact_start();
        touch_diagnostic(x, y);
    }
    if (gesture != wearable::TouchGesture::None) touch_gesture(gesture, now);
    if (!pressed) { s_touch_reducer.reset(); s_touch_contact_valid = false; }
}
wearable::ShellView shell_view() {
    wearable::ShellView view;
    view.selected=s_pi.selected;view.wifi=s_wifi_connected;view.gateway=s_cloud_connected;
    view.agent=s_pi.agent;view.session=s_pi.name;view.model=s_pi.model;view.effort=s_pi.effort;view.state=s_pi.state;
    static std::string project;
    project=s_pi.cwd;
    while(project.size()>1 && project.back()=='/') project.pop_back();
    const auto slash=project.find_last_of('/');if(slash!=std::string::npos) project=project.substr(slash+1);
    if(project.empty()) project=s_pi.name;
    view.project=project.c_str();
    view.activity_type=s_pi.activity_type;view.activity=s_pi.latest_activity;view.recent=s_pi_tail?s_pi_tail:"";
    view.final_text=s_pi.has_final_text?s_pi.final_text:nullptr;
    // Manual browse page count must be identical to the renderer's, so the page
    // index the user swipes to is exactly the page that is drawn. The renderer
    // owns one pagination implementation; this reuses it instead of keeping a
    // second estimate (font, wrapping, rows, boundaries all match by
    // construction).
    {
        const auto shown=agent_display_text(s_pi.state,view.final_text,s_pi.activity_type,
            s_pi.latest_activity,s_pi_tail?s_pi_tail:"");
        const char* text=shown.text;
        if(!text||!*text)text="Waiting for agent activity...";
        const auto* previous_font=s_display.getFont();
        const auto layout=wearable::ShellRenderer<StopWatchDisplay,wearable::FontBook>::agent_body_layout(
            s_display,s_fonts,s_ui,text);
        s_display.setFont(previous_font);
        s_agent_browse.set_pages(layout.pages);
        view.manual=s_agent_browse.manual;
        view.page_index=s_agent_browse.page;
        view.page_count=layout.pages;
    }
    view.now_ms=esp_timer_get_time()/1000;view.last_update_ms=s_pi.received_at_ms;
    view.elapsed=s_pi.elapsed_seconds;
    if(!strcmp(s_pi.state,"RUNNING") && view.now_ms>=s_pi.received_at_ms)
        view.elapsed+=(view.now_ms-s_pi.received_at_ms)/1000;
    static char time[16],date[40],battery[24],battery_detail[48];
    const auto local_ms=s_clock.local_ms(view.now_ms);
    if(local_ms) {
        const time_t local=time_t(local_ms/1000);struct tm broken{};gmtime_r(&local,&broken);
        strftime(time,sizeof(time),"%H:%M",&broken);strftime(date,sizeof(date),"%a %b %d",&broken);
    } else {strlcpy(time,"--:--",sizeof(time));strlcpy(date,"Time unavailable",sizeof(date));}
    if(s_battery.valid) {
        snprintf(battery,sizeof(battery),"BAT %u%%",s_battery.percent);
        snprintf(battery_detail,sizeof(battery_detail),"Battery %u%%  %.2fV",s_battery.percent,s_battery.filtered_millivolts/1000.0);
    } else {strlcpy(battery,"BAT --",sizeof(battery));strlcpy(battery_detail,"Battery --",sizeof(battery_detail));}
    view.battery_detail=battery_detail;
    view.battery_valid=s_battery.valid;view.battery_percent=s_battery.percent;
    // Watch glance rows derive from already-cached state; no new request or timer.
    static char task[32];
    const char *task_label=wearable::task_state_label(s_pi.state,s_pi.selected,s_cloud_connected);
    strlcpy(task,task_label,sizeof(task));
    view.task_state=task;
    view.task_tone=wearable::task_state_tone(s_pi.state,s_pi.selected,s_cloud_connected);
    view.host_state=wearable::host_state_label(s_server.host);
    view.host_tone=wearable::host_state_tone(s_server.host);
    static char codex_percent[8];
    wearable::codex_percent_text(s_server.codex,codex_percent,sizeof(codex_percent));
    view.codex_percent=codex_percent;
    view.codex_reset=s_server.codex_reset_text;
    view.codex_valid=s_server.codex.valid;
    view.time=time;view.date=date;view.battery=battery;view.rssi=s_wifi_rssi;
    view.firmware=s_version;view.wifi_detail=s_wifi_detail;view.save_feedback=s_preference_feedback.c_str();
    return view;
}
void draw_shell_app() {
    const auto view=shell_view();
    wearable::ShellRenderer<StopWatchDisplay,wearable::FontBook> renderer(s_display,s_fonts,s_ui,view);
    if(s_ui.app==wearable::App::Watch) {
        renderer.watch();s_watch_task_redraw_pending=false;
        s_last_watch_clock_minute=s_clock.local_ms(view.now_ms)/60000;
        s_last_watch_elapsed=view.elapsed;
    }
    else if(s_ui.app==wearable::App::Help) renderer.help();
    else renderer.agent();
}
void draw_local_settings() {
    const auto view=shell_view();
    wearable::ShellRenderer<StopWatchDisplay,wearable::FontBook>(s_display,s_fonts,s_ui,view).settings();
}
void draw_input_modal() {
    if(s_input_modal==InputModal::SETTINGS && s_settings_phase=="NAV") {draw_navigator();return;}
    if(s_input_modal==InputModal::SETTINGS && s_settings_phase=="HISTORY") {draw_history();return;}
    if(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU") {draw_local_settings();return;}
    draw_header(s_input_modal==InputModal::SETTINGS?"SETTINGS":s_input_modal==InputModal::ERROR?"ERROR":"VOICE");
    s_fonts.apply(s_display,wearable::TypeRole::Meta,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    s_display.drawString(std::strcmp(s_pi.agent,"codex")==0?"CODEX":"PI AGENT",234,104);
    s_display.drawString(s_pi.name,234,136);
    std::string body, footer="B CLOSE";
    if (s_input_modal==InputModal::ERROR) body=s_modal_error;
    else if (s_input_modal==InputModal::SETTINGS) {
        body=s_settings_phase=="SPEECH"?s_speech_menu.label():s_settings_phase;
        if (!s_models.empty()) {
            const auto &m=s_models[s_model_index];
            body+="\n\n"+(s_settings_phase=="EFFORT"?m.efforts[s_effort_index]:m.id);
            if (s_models.size()==1 && s_settings_phase=="MODEL") body+="\nNO ALTERNATIVES";
        }
        footer="A NEXT   B SELECT";
        if ((s_settings_phase=="SPEECH" && s_speech_menu.page==SpeechMenu::Page::MODE) || s_settings_phase=="EFFORT" || (!s_models.empty() && s_models[s_model_index].efforts.empty())) footer="A NEXT   B APPLY";
        if (s_settings_phase=="MODEL UPDATED" || s_settings_phase=="UPDATE FAILED" || s_settings_phase=="APPLYING" || s_settings_phase=="LOADING") footer=(s_settings_phase=="APPLYING" || s_settings_phase=="LOADING")?s_settings_phase:"";
    } else if (s_voice_phase=="RECORDING") {
        s_voice_started=audio_backend_record_started_ms(s_voice_serial);
        char duration[32];snprintf(duration,sizeof(duration),"00:%02llu",static_cast<unsigned long long>(s_voice_started?(esp_timer_get_time()/1000-s_voice_started)/1000:0));
        body=std::string("LISTENING...\n\n")+duration;footer="Release A to finish";
    } else if (s_voice_phase=="AWAITING_CONFIRMATION") { body=s_transcript;footer=s_nav.page==wearable::AgentNavigator::Page::FirstMessage?"A START   B CANCEL":"A SEND   B CANCEL"; }
    else {body=s_voice_phase;footer=s_voice_phase=="Recording too short"?"":"B CLOSE";}
    s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    auto lines=wrap_activity_text(body.c_str(),304);
    // No touch required: long transcripts page automatically, preserving all text.
    const size_t count=std::max<int32_t>(1,(360-184)/(s_display.fontHeight()+6));
    size_t start=lines.size()>count?((esp_timer_get_time()/1000000/4)%((lines.size()+count-1)/count))*count:0;
    s_display.setTextDatum(textdatum_t::top_left);
    const int line=s_display.fontHeight()+6;
    for (size_t i=start;i<std::min(start+count,lines.size());++i) s_display.drawString(lines[i].c_str(),82,184+(i-start)*line);
    s_fonts.apply(s_display,wearable::TypeRole::Footer,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).accent,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    s_display.drawString(footer.c_str(),234,s_input_modal==InputModal::SETTINGS?374:386);
    if(s_input_modal==InputModal::SETTINGS) {
        const bool busy=s_settings_phase=="LOADING" || s_settings_phase=="APPLYING" || s_settings_phase=="SAVING SPEECH";
        s_display.drawString(busy?"Busy   Hold B Close":"Hold A Back   Hold B Close",234,405);
    }
}

void draw_pi()
{
    const auto* previous=s_display.getFont();
    draw_shell_app();
    s_display.setFont(previous);
}

void draw_approval()
{
    draw_header("APPROVAL");
    s_fonts.apply(s_display,wearable::TypeRole::StatusPrimary,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).approval,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    if (s_approval.result_until) {
        s_display.drawString(s_approval.result.c_str(), 234, 225);
        return;
    }
    const auto *view = s_approval.current();
    if (!view) return;
    std::string agent = view->agent;
    for (char &c : agent) c = std::toupper(static_cast<unsigned char>(c));
    s_display.drawString(agent.c_str(), 234, 104);
    s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    const int line=s_display.fontHeight()+5;
    auto titles = wrap_activity_text(view->title.c_str(), 304);
    for (size_t i = 0; i < std::min<size_t>(titles.size(), 2); ++i)
        s_display.drawString(titles[i].c_str(), 234, 146 + i * line);
    auto lines = wrap_activity_text(view->detail.c_str(), 304);
    const size_t maximum=std::max<int32_t>(1,(358-205)/line);
    if (lines.size() > maximum) { lines.resize(maximum); lines.back() = "..."; }
    s_display.setTextDatum(textdatum_t::top_left);
    for (size_t i = 0; i < lines.size(); ++i)
        s_display.drawString(lines[i].c_str(), 82, 205 + i * line);
    s_fonts.apply(s_display,wearable::TypeRole::Footer,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).done,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    std::string choice_a="A APPROVE",choice_b="B REJECT";
    if(view->choice_count==2) {choice_a=std::string("A ")+wearable::native_decision_label(view->choices[0].c_str());choice_b=std::string("B ")+wearable::native_decision_label(view->choices[1].c_str());}
    else if(view->choice_count) {choice_a=wearable::native_decision_label(view->choices[s_approval.choice_index].c_str());choice_b="A NEXT   B SELECT";}
    s_display.drawString(s_approval.submitted ? "SENDING..." : choice_a.c_str(),234,374);
    if (!s_approval.submitted) {
        s_display.setTextColor(wearable::palette(s_ui.preferences.theme).failed, TFT_BLACK);
        s_display.drawString(choice_b.c_str(),234,404);
    }
}

void draw_status(const char *reason)
{
    // Wi-Fi sys_evt has a small stack. Callbacks only request work; main owns
    // fonts, layout and panel transactions. Keep the established display lock.
    if(s_display_owner && xTaskGetCurrentTaskHandle()!=s_display_owner) {
        s_draw_requested=true;return;
    }
    if (!s_display_ready) {
        return;
    }
    lock_display();
    ESP_LOGI(TAG,"[DRAW] reason=%s page=%s modal=%s speech=%d",reason,
             page_name(s_page),modal_name(s_input_modal),!s_speech.id.empty());
    char state[320];
    snprintf(state,sizeof(state),"page=%s modal=%s settings_phase=%s menu=%s index=%u mode=%u voice_phase=%s ui_epoch=%llu approval=%d",
             page_name(s_page),modal_name(s_input_modal),s_settings_phase.c_str(),
             settings_page_name(s_speech_menu.page),s_speech_menu.index,s_speech_menu.mode,
             s_voice_phase.rfind("VOICE ERROR",0)==0?"VOICE_ERROR":s_voice_phase.c_str(),
             static_cast<unsigned long long>(s_ui_epoch),s_approval.modal());
    static std::string last_state;
    const bool changed=last_state!=state;
    if (changed) { last_state=state; ESP_LOGI(TAG,"[UI] draw begin %s",state); }
    struct DrawDiagnostic {
        bool changed; int64_t started;size_t internal_before,largest_before,psram_before;
        ~DrawDiagnostic() {
            // The outer transaction keeps every primitive in the framebuffer.
            // endWrite submits the completed frame once, before releasing its lock.
            s_display.endWrite();
            if (changed) {
                const auto internal_after=heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
                const auto largest_after=heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL);
                const auto psram_after=heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
                ESP_LOGI(TAG,"[UI_PERF] draw complete elapsed_ms=%lld internal_delta=%lld internal_free=%u largest_delta=%lld largest=%u psram_delta=%lld psram_free=%u glyph_missing=%lu",
                    static_cast<long long>((esp_timer_get_time()-started)/1000),
                    static_cast<long long>(internal_after)-static_cast<long long>(internal_before),
                    static_cast<unsigned>(internal_after),
                    static_cast<long long>(largest_after)-static_cast<long long>(largest_before),
                    static_cast<unsigned>(largest_after),
                    static_cast<long long>(psram_after)-static_cast<long long>(psram_before),
                    static_cast<unsigned>(psram_after),static_cast<unsigned long>(s_fonts.missing()));
            }
            unlock_display();
        }
    } diagnostic{changed,esp_timer_get_time(),heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
        heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),heap_caps_get_free_size(MALLOC_CAP_SPIRAM)};
    s_display.startWrite();
    s_display.fillScreen(TFT_BLACK);
    s_display.setFont(&fonts::Font0);
    s_display.setTextSize(1);
    if (s_approval.modal()) {
        draw_approval();
        return;
    }
    if(s_input_modal==InputModal::SETTINGS && s_settings_phase=="NAV" && s_nav.busy() && s_nav.deadline && uint64_t(esp_timer_get_time()/1000)>s_nav.deadline) {
        s_nav.deadline=0;
        if(s_nav.loading==NavPage::Sessions)queue_control(navigator_command("target_get",false));
        else {s_nav.page=NavPage::Error;s_nav.status.set("Offline / timed out\nHold A to retry picker");}
    }
    if (s_input_modal != InputModal::NONE) { draw_input_modal(); return; }
    if (!s_speech.id.empty()) {
        draw_header("SPEAKING");
        s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                      wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
        s_display.setTextDatum(textdatum_t::top_center);s_display.drawString(s_pi.selected?s_pi.agent:"SCREEN",234,125);
        const auto lines=wrap_activity_text(s_speech_text.c_str(),304);
        s_display.setTextDatum(textdatum_t::top_left);
        const int line=s_display.fontHeight()+6;const size_t count=std::max<int32_t>(1,(390-184)/line);
        for (size_t i=0;i<std::min(count,lines.size());++i) s_display.drawString(lines[i].c_str(),82,184+i*line);
        return;
    }
    if(s_message_visible) {
        draw_header(s_message_title.c_str());
        s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                      wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
        s_display.setTextDatum(textdatum_t::top_left);
        const auto lines=wrap_activity_text(s_message_body.c_str(),304,false);
        const int line=s_display.fontHeight()+6;const size_t count=std::max<int32_t>(1,(390-160)/line);
        for(size_t i=0;i<std::min(count,lines.size());++i) s_display.drawString(lines[i].c_str(),82,160+i*line);
        return;
    }
    if(s_ui.app!=wearable::App::Monitor) {
        draw_shell_app();s_pi_redraw_pending=false;s_last_pi_redraw_ms=esp_timer_get_time()/1000;return;
    }
    switch (s_page) {
    case Page::OVERVIEW:
        draw_overview();
        break;
    case Page::CPU:
        draw_cpu();
        break;
    case Page::MEMORY:
        draw_memory();
        break;
    case Page::DISK:
        draw_disk();
        break;
    case Page::NETWORK:
        draw_network();
        break;
    case Page::PI:
        draw_pi();
        s_pi_redraw_pending=false;
        s_last_pi_redraw_ms=esp_timer_get_time()/1000;
        break;
    default:
        break;
    }
    if(s_page!=Page::OVERVIEW) draw_footer();
}

void vibrate_task(void *arg)
{
    if(!s_vibration_enabled.load()) {vTaskDelete(nullptr);return;}
    const uint32_t duration = reinterpret_cast<uintptr_t>(arg);
    const m5ioe1_err_t start = s_ioe.setPwmDuty(
        MOTOR_PWM_CHANNEL, GENERIC_VIBRATION_DUTY, false, true);
    vTaskDelay(pdMS_TO_TICKS(duration));
    const m5ioe1_err_t stop = s_ioe.setPwmDuty(MOTOR_PWM_CHANNEL, 0, false, true);
    if (start != M5IOE1_OK || stop != M5IOE1_OK) {
        ESP_LOGW(TAG, "vibration runtime failure: start=%d stop=%d", static_cast<int>(start),
                 static_cast<int>(stop));
    }
    ESP_LOGI(TAG, "[NOTIFY] vibration complete stack_free_min_bytes=%u",
             static_cast<unsigned>(uxTaskGetStackHighWaterMark(nullptr)));
    vTaskDelete(nullptr);
}

void pi_notify_task(void *arg)
{
    const auto kind = reinterpret_cast<uintptr_t>(arg);
    const bool failed = kind == 1;
    const VibrationStep *pattern = kind == 2 ? APPROVAL_VIBRATION_PATTERN.data() : failed
        ? FAILED_VIBRATION_PATTERN.data()
        : DONE_VIBRATION_PATTERN.data();
    const size_t pattern_size = kind == 2 ? APPROVAL_VIBRATION_PATTERN.size() : failed
        ? FAILED_VIBRATION_PATTERN.size()
        : DONE_VIBRATION_PATTERN.size();
    for (size_t pulse = 0; pulse < pattern_size; ++pulse) {
        if(!s_vibration_enabled.load()) break; // Always finish an already-started pulse with stop.
        const m5ioe1_err_t start = s_ioe.setPwmDuty(
            MOTOR_PWM_CHANNEL, NOTIFY_VIBRATION_DUTY, false, true);
        vTaskDelay(pdMS_TO_TICKS(pattern[pulse].on_ms));
        const m5ioe1_err_t stop = s_ioe.setPwmDuty(
            MOTOR_PWM_CHANNEL, 0, false, true);
        if (start != M5IOE1_OK || stop != M5IOE1_OK) {
            ESP_LOGW(TAG, "notification vibration failure: start=%d stop=%d",
                     static_cast<int>(start), static_cast<int>(stop));
        }
        if (pattern[pulse].off_ms > 0) {
            vTaskDelay(pdMS_TO_TICKS(pattern[pulse].off_ms));
        }
    }
    ESP_LOGI(TAG, "[NOTIFY] pattern complete kind=%u stack_free_min_bytes=%u",
             static_cast<unsigned>(kind),
             static_cast<unsigned>(uxTaskGetStackHighWaterMark(nullptr)));
    vTaskDelete(nullptr);
}

void probe_battery(bool initial) {
    const uint64_t now_ms=esp_timer_get_time()/1000;
    const bool diagnose=initial || now_ms>=s_next_power_log_ms;
    // All PM1 access after init lives in this worker; no I2C in rendering.
    uint16_t vbat=0,vin=0,vref=0;uint8_t charge_status=1;
    m5pm1_pwr_src_t source=M5PM1_PWR_SRC_UNKNOWN;
    const auto vbat_result=s_pmic.readVbat(&vbat);
    auto vin_result=M5PM1_ERR_NOT_INIT,vref_result=M5PM1_ERR_NOT_INIT;
    auto source_result=M5PM1_ERR_NOT_INIT,charge_result=M5PM1_ERR_NOT_INIT;
    if(diagnose) {
        vin_result=s_pmic.readVin(&vin);vref_result=s_pmic.readVref(&vref);
        source_result=s_pmic.getPowerSource(&source);
        charge_result=s_pmic.gpioGetInput(PMG2_CHG_STAT,&charge_status);
    }
    lock_display();
    s_battery.sample(int(vbat_result),vbat);
    if(vin_result==M5PM1_OK) s_battery.external_power=vin>4000;
    if(vin_result==M5PM1_OK && charge_result==M5PM1_OK)
        s_battery.charging=s_battery.external_power && charge_status==0;
    if(diagnose) {
        s_next_power_log_ms=now_ms+60000;
        ESP_LOGI(TAG,"[POWER] initial=%d vbat_result=%d vbat_mv=%u filtered_mv=%u percent=%u vin_result=%d vin_mv=%u vref_result=%d vref_mv=%u power_source_result=%d power_source=%d charge_status_result=%d charge_status=%u battery_valid=%d failures=%lu last_error=%d",
            initial,int(vbat_result),vbat,s_battery.filtered_millivolts,s_battery.percent,int(vin_result),vin,
            int(vref_result),vref,int(source_result),int(source),int(charge_result),charge_status,
            s_battery.valid,(unsigned long)s_battery.failures,s_battery.last_error);
    }
    unlock_display();
}
void device_status_task(void*) {
    // Suppress repetitive vendor errors; the once/minute POWER record has exact
    // return codes, including -8 NOT_INIT vs OK with an invalid voltage.
    esp_log_level_set("M5PM1_ADC",ESP_LOG_NONE);
    while(true) {
        lock_display();
        const bool recording=s_input_modal==InputModal::VOICE && s_voice_phase=="RECORDING";
        unlock_display();
        if(!recording) {
            probe_battery(false);
            wifi_ap_record_t ap{};const auto wifi=esp_wifi_sta_get_ap_info(&ap);
            lock_display();s_wifi_rssi=wifi==ESP_OK?ap.rssi:-127;unlock_display();
        }
        vTaskDelay(pdMS_TO_TICKS(3000));
    }
}

}  // namespace

esp_err_t board_init(const char *firmware_version)
{
    s_display_owner=xTaskGetCurrentTaskHandle();
    strlcpy(s_version, firmware_version, sizeof(s_version));
    s_display_lock = xSemaphoreCreateRecursiveMutex();
    if (s_display_lock == nullptr) {
        set_last_error("display mutex", ESP_ERR_NO_MEM);
        return ESP_ERR_NO_MEM;
    }

#if defined(CONFIG_ESP32S3_SPIRAM_SUPPORT) && defined(CONFIG_SPIRAM_MODE_OCT)
    constexpr bool psram_build_config = true;
#else
    constexpr bool psram_build_config = false;
#endif
    const bool psram_runtime = esp_psram_is_initialized();
    const size_t psram_size = psram_runtime ? esp_psram_get_size() : 0;
    ESP_LOGI(TAG, "[DIAG] PSRAM compile_opi=%s runtime=%s size=%u bytes",
             psram_build_config ? "yes" : "no", psram_runtime ? "OK" : "FAIL",
             static_cast<unsigned>(psram_size));
    if (!psram_build_config || !psram_runtime || psram_size == 0) {
        set_last_error("OPI PSRAM", ESP_ERR_NOT_SUPPORTED);
        ESP_LOGE(TAG, "[DIAG] OPI PSRAM is required for the StopWatch framebuffer");
        return ESP_ERR_NOT_SUPPORTED;
    }
    s_pi_tail = static_cast<char *>(heap_caps_calloc(
        PI_LOCAL_TAIL_BYTES + 1, sizeof(char), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    if (s_pi_tail == nullptr) {
        set_last_error("Pi activity PSRAM", ESP_ERR_NO_MEM);
        return ESP_ERR_NO_MEM;
    }
    ESP_LOGI(TAG, "[DIAG] Pi activity tail: OK PSRAM=%u bytes",
             static_cast<unsigned>(PI_LOCAL_TAIL_BYTES));

    esp_err_t result = initialize_i2c();
    if (result != ESP_OK) {
        return result;
    }
    initialize_rtc_nonfatal();
    result = initialize_pmic();
    if (result != ESP_OK) {
        return result;
    }
    result = initialize_ioe();
    if (result != ESP_OK) {
        return result;
    }
    probe_battery(true); // Probe after both vendor objects have initialized.
    vTaskDelay(pdMS_TO_TICKS(50));
    result = initialize_display();
    if (result != ESP_OK) {
        return result;
    }
    s_saved_preferences=preferences_load();s_ui.boot(s_saved_preferences.value);sync_display_page();
    if(s_rtc_boot_utc_ms) s_clock.sync(double(s_rtc_boot_utc_ms),esp_timer_get_time()/1000,
        s_ui.preferences.timezone_offset_minutes);
    s_vibration_enabled=s_ui.preferences.vibration_enabled;
    lock_display();s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));unlock_display();
    ESP_LOGI(TAG,"[PREFS] load slot=%d generation=%lu readonly=%d brightness=%u home=%u text=%u theme=%u vibration=%u",
        s_saved_preferences.slot,(unsigned long)s_saved_preferences.generation,s_saved_preferences.read_only,
        s_ui.preferences.brightness,unsigned(s_ui.preferences.home_screen),unsigned(s_ui.preferences.text_size),
        unsigned(s_ui.preferences.theme),s_ui.preferences.vibration_enabled);
    if(xTaskCreate(device_status_task,"device_status",4096,nullptr,2,nullptr)!=pdPASS)
        ESP_LOGW(TAG,"[STATUS] battery polling unavailable (nonfatal)");
    diagnose_touch_and_optional_i2c();
    initialize_buttons();
    initialize_touch_nonfatal();
    initialize_audio_nonfatal();

    strlcpy(s_last_error, "none", sizeof(s_last_error));
    draw_status();
    return ESP_OK;
}

const char *board_slug(void) { return "m5stack-stopwatch"; }
const char *board_display_name(void) { return "M5Stack StopWatch"; }
const char *board_last_error(void) { return s_last_error; }

void board_set_device_id(const char *device_id)
{
    strlcpy(s_device_id, device_id, sizeof(s_device_id));
    draw_status();
}

void board_set_wifi_status(bool connected, const char *detail)
{
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    s_wifi_connected = connected;
    strlcpy(s_wifi_detail, detail == nullptr ? (connected ? "connected" : "disconnected") : detail,
            sizeof(s_wifi_detail));
    draw_status();
}

void board_set_cloud_status(bool connected)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    s_cloud_connected = connected;
    if (!connected) {
        // A cached snapshot is not an online host. Wait for a fresh snapshot
        // after reconnect instead of restoring its previous load classification.
        s_server.host=wearable::HostState::Offline;
        s_server.has_host_state=false;
        const bool interrupted_voice = s_input_modal==InputModal::VOICE && s_voice_phase!="SENT";
        stop_speech(false);
        s_message_visible = false;
        if(!(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU"))
            cancel_input(false,"cloud-disconnect");
        s_control_queue.clear();s_nav.native_request.set("");
        s_nav.catalog=s_nav.target_select=s_nav.permissions=s_nav.native=s_nav.create=false;
        s_ui.agent_capabilities(false,false,false,false);
        s_approval.disconnect();
        s_approval_action = {};
        s_notified_approval.clear();
        if (interrupted_voice) {
            s_input_modal=InputModal::ERROR;
            s_modal_error="Voice interrupted\nTry again";
        }
    }
    draw_status();
}

void board_set_server_snapshot(const board_server_snapshot_t *snapshot)
{
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    if (snapshot == nullptr) {
        return;
    }
    strlcpy(s_server.hostname,
            snapshot->hostname == nullptr || snapshot->hostname[0] == '\0'
                ? "LIGHTHOUSE"
                : snapshot->hostname,
            sizeof(s_server.hostname));
    for (char *cursor = s_server.hostname; *cursor != '\0'; ++cursor) {
        *cursor = static_cast<char>(std::toupper(static_cast<unsigned char>(*cursor)));
    }
    s_server.cpu_percent = snapshot->cpu_percent;
    s_server.load_1m = snapshot->load_1m;
    s_server.load_5m = snapshot->load_5m;
    s_server.memory_used = snapshot->memory_used;
    s_server.memory_total = snapshot->memory_total;
    s_server.memory_percent = snapshot->memory_percent;
    s_server.swap_percent = snapshot->swap_percent;
    s_server.disk_used = snapshot->disk_used;
    s_server.disk_total = snapshot->disk_total;
    s_server.disk_percent = snapshot->disk_percent;
    s_server.network_rx_bytes = snapshot->network_rx_bytes;
    s_server.network_tx_bytes = snapshot->network_tx_bytes;
    s_server.network_rx_bps = snapshot->network_rx_bps;
    s_server.network_tx_bps = snapshot->network_tx_bps;
    s_server.uptime = snapshot->uptime;
    s_server.valid = true;
    // Derive the Watch glance state. Identical derived values must not repaint:
    // the gateway pushes every ~2 s and a full redraw per frame is wasteful.
    const bool had_state = s_server.has_host_state;
    const auto previous = had_state ? s_server.host : wearable::HostState::Offline;
    auto next = wearable::host_state(s_server.cpu_percent, s_server.memory_percent, true,
                                     s_cloud_connected, previous, had_state);
    // Prefer the gateway's classification when an older/newer one is present,
    // but never let a missing string downgrade a live host to Offline.
    if (s_cloud_connected && snapshot->host_state != nullptr && snapshot->host_state[0] != '\0') {
        if (!std::strcmp(snapshot->host_state, "Busy")) next = wearable::HostState::Busy;
        else if (!std::strcmp(snapshot->host_state, "Active")) next = wearable::HostState::Active;
        else if (!std::strcmp(snapshot->host_state, "Idle")) next = wearable::HostState::Idle;
        else if (!std::strcmp(snapshot->host_state, "Offline")) next = wearable::HostState::Offline;
    }
    const bool host_changed = !had_state || next != s_server.host;
    s_server.host = next;
    s_server.has_host_state = true;
    wearable::CodexQuota quota{};
    quota.valid = snapshot->codex_quota_valid;
    quota.remaining_percent = uint8_t(snapshot->codex_remaining_percent > 100 ? 100 :
        snapshot->codex_remaining_percent);
    quota.resets_at = snapshot->codex_resets_at;
    quota.has_reset = snapshot->codex_resets_at > 0;
    quota.has_window = snapshot->codex_window_minutes > 0;
    quota.window_minutes = snapshot->codex_window_minutes;
    char reset_text[24] = "";
    if (quota.valid) {
        wearable::LocalClock now{};
        const auto local_ms = s_clock.local_ms(esp_timer_get_time() / 1000);
        if (local_ms) { now.local_seconds = local_ms / 1000; now.valid = true; }
        wearable::codex_reset_text(quota, now, reset_text, sizeof(reset_text));
    }
    const bool codex_changed = wearable::codex_quota_changes_display(
        s_server.codex, quota, s_server.codex_reset_text, reset_text);
    s_server.codex = quota;
    memcpy(s_server.codex_reset_text, reset_text, sizeof(s_server.codex_reset_text));
    if ((host_changed || codex_changed) &&
        (server_snapshot_needs_redraw(s_page, page_overlay_visible()) ||
         watch_snapshot_needs_redraw(s_page, shell_app(), page_overlay_visible(),
                                     host_changed || codex_changed))) {
        draw_status(host_changed ? "host_state" : "codex_quota");
    }
}

void board_select_server_target(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if (s_pi.selected) stop_speech();
    cancel_input(true,"server-target");
    s_pi.selected = false;
    s_message_visible = false;
    draw_status();
}

void board_set_pi_snapshot(const board_pi_snapshot_t *snapshot)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if (snapshot == nullptr) {
        return;
    }
    const bool changed_target = agent_target_changed(s_pi.selected,s_pi.agent,s_pi.session_id,snapshot->agent,snapshot->session_id);
    const bool changed_view=pi_snapshot_changes_view(*snapshot);
    const auto now_ms=uint64_t(esp_timer_get_time()/1000);
    uint64_t shown_elapsed=s_pi.elapsed_seconds;
    if(!std::strcmp(s_pi.state,"RUNNING") && now_ms>=s_pi.received_at_ms)
        shown_elapsed+=(now_ms-s_pi.received_at_ms)/1000;
    const bool changed_watch=wearable::watch_task_changes_display(s_pi.state,snapshot->state,
        s_pi.selected,true,s_cloud_connected,shown_elapsed,snapshot->elapsed_seconds);
    const bool had_overlay=page_overlay_visible();
    ESP_LOGI(TAG,"[AGENT] snapshot agent=%s session=%s changed_target=%s changed_view=%d state=%s page=%s modal=%s final_bytes=%u",
             snapshot->agent?snapshot->agent:"pi",snapshot->session_id?snapshot->session_id:"unknown",
             changed_target?"true":"false",changed_view,snapshot->state?snapshot->state:"OFFLINE",
             page_name(s_page),modal_name(s_input_modal),
             static_cast<unsigned>(snapshot->final_text?std::strlen(snapshot->final_text):0));
    if (changed_target) {
        ESP_LOGI(TAG,"[AGENT] target old_agent=%s new_agent=%s old_session=%s new_session=%s selected=%d",
                 s_pi.agent,snapshot->agent?snapshot->agent:"pi",s_pi.session_id,
                 snapshot->session_id?snapshot->session_id:"unknown",s_pi.selected);
        stop_speech();
        // A new session resets the manual browse page and drops any cached or
        // in-flight history from the previous target.
        s_agent_browse.reset();
        s_history_cache.clear();
        s_history_screen.load({},{},{},{});
        s_history_request.clear();
        s_history_loading=false;
        if(!(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU")) cancel_input(true,"target-change");
    }
    strlcpy(s_pi.agent,snapshot->agent?snapshot->agent:"pi",sizeof(s_pi.agent));
    strlcpy(s_pi.model,snapshot->model?snapshot->model:"",sizeof(s_pi.model));
    strlcpy(s_pi.effort,snapshot->effort?snapshot->effort:"",sizeof(s_pi.effort));
    strlcpy(s_pi.session_id, snapshot->session_id == nullptr ? "unknown" : snapshot->session_id,
            sizeof(s_pi.session_id));
    copy_agent_final(s_pi.name,snapshot->name == nullptr ? s_pi.session_id : snapshot->name);
    copy_agent_final(s_pi.cwd,snapshot->cwd);
    strlcpy(s_pi.state, snapshot->state == nullptr ? "OFFLINE" : snapshot->state,
            sizeof(s_pi.state));
    strlcpy(s_pi.activity_type,
            snapshot->activity_type == nullptr ? "" : snapshot->activity_type,
            sizeof(s_pi.activity_type));
    strlcpy(s_pi.latest_activity,
            snapshot->latest_activity == nullptr ? "" : snapshot->latest_activity,
            sizeof(s_pi.latest_activity));
    set_pi_tail(snapshot->recent_text);
    s_pi.has_final_text=snapshot->final_text!=nullptr;
    copy_agent_final(s_pi.final_text,snapshot->final_text);
    s_pi.elapsed_seconds = snapshot->elapsed_seconds;
    s_pi.received_at_ms = esp_timer_get_time() / 1000;
    s_pi.selected = true;
    s_watch_task_redraw_pending=s_watch_task_redraw_pending || changed_watch;
    if (changed_target) {
        s_message_visible = false;
        ESP_LOGI(TAG,"[UI] backing target changed; app=%u preserved",unsigned(s_ui.app));
        if(s_ui.app!=wearable::App::Watch || changed_watch || had_overlay)
            draw_status("agent_target_change");
    } else {
        // Keep activity/timer work already pending. board_update coalesces all
        // visible Agent changes; hidden backing state is still updated above.
        s_pi_redraw_pending=s_pi_redraw_pending || changed_view;
    }
}

void board_append_pi_activity(const char *activity_type, const char *activity)
{
    if (!s_pi.selected || activity == nullptr || activity[0] == '\0') {
        return;
    }
    strlcpy(s_pi.activity_type, activity_type == nullptr ? "text" : activity_type,
            sizeof(s_pi.activity_type));
    strlcpy(s_pi.latest_activity, activity, sizeof(s_pi.latest_activity));
    append_pi_tail(s_pi.activity_type, activity);
    s_pi_redraw_pending = true;
}

void board_notify_pi(bool failed)
{
    if (s_vibrator_ready && s_vibration_enabled.load()) {
        if (xTaskCreate(pi_notify_task, "pi_notify", VIBRATION_TASK_STACK_BYTES,
                        reinterpret_cast<void *>(failed ? 1U : 0U), 3, nullptr) != pdPASS) {
            ESP_LOGW(TAG, "notification vibration task allocation failed");
        }
    }
    if (s_audio_ready &&
        !audio_backend_notify(failed ? audio_notification_t::FAILED : audio_notification_t::DONE)) {
        ESP_LOGW(TAG, "[AUDIO] notification queue full");
    }
}

void board_next_page(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if(s_approval.modal() || s_input_modal!=InputModal::NONE) return;
    s_message_visible=false;s_ui.navigate(true);sync_display_page();++s_ui_epoch;
    ESP_LOGI(TAG,"[SHELL] A app=%u",unsigned(s_ui.app));
    draw_status();
}
void board_previous_page(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if(s_approval.modal() || s_input_modal!=InputModal::NONE) return;
    s_message_visible=false;s_ui.navigate(false);sync_display_page();++s_ui_epoch;
    ESP_LOGI(TAG,"[SHELL] B app=%u",unsigned(s_ui.app));
    draw_status();
}

void board_show_message(const char *title, const char *body)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if(s_approval.modal() || !s_display_ready) return;
    s_message_visible=true;s_message_until_ms=esp_timer_get_time()/1000+5000;
    s_message_title=title?title:"";s_message_body=body?body:"";
    draw_status("message");
}

void board_vibrate(uint32_t duration_ms)
{
    if (!s_vibrator_ready || !s_vibration_enabled.load()) {
        return;
    }
    duration_ms = std::min<uint32_t>(duration_ms, 500);
    xTaskCreate(vibrate_task, "vibrate", VIBRATION_TASK_STACK_BYTES, reinterpret_cast<void *>(duration_ms), 3, nullptr);
}

void board_update(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    const uint64_t now_ms = esp_timer_get_time() / 1000;
    if(s_draw_requested.exchange(false)) draw_status("callback_request");
    if (!s_speech.id.empty() && !audio_backend_speech_active(s_speech_generation)) {
        stop_speech(!s_speech.ended);draw_status();
    }
    if (s_approval.tick(now_ms)) draw_status();
    const auto *current = s_approval.current();
    if (current && current->id != s_notified_approval) {
        s_notified_approval = current->id;
        if (s_vibrator_ready && s_vibration_enabled.load()) xTaskCreate(pi_notify_task, "approval_notify", VIBRATION_TASK_STACK_BYTES, reinterpret_cast<void *>(2U), 3, nullptr);
        if (s_audio_ready) audio_backend_notify(audio_notification_t::APPROVAL);
    }
    poll_shell_button(s_button_a,true,gpio_get_level(BUTTON_A_PIN)==0,now_ms);
    poll_shell_button(s_button_b,false,gpio_get_level(BUTTON_B_PIN)==0,now_ms);
    // Touch: only read I2C while the controller asserts INT or a contact is in
    // progress. Idle never polls I2C. The ISR itself only sets a flag.
    {
        const bool irq = s_touch_irq.exchange(false, std::memory_order_relaxed);
        const bool contact = s_touch_down.load(std::memory_order_relaxed);
        if (irq || contact) {
            // While a finger is down, sample often enough for smooth movement;
            // when idle, only the INT edge triggers a single read.
            if (!contact || now_ms - s_touch_last_sample >= TOUCH_SAMPLE_INTERVAL_MS) {
                s_touch_last_sample = now_ms;
                poll_shell_touch(true, now_ms);
            }
        }
    }
    if (s_feedback_until && now_ms>=s_feedback_until) {
        const bool return_to_navigator=s_voice_phase=="Recording too short" &&
            (s_nav.page==NavPage::NativeInput || s_nav.page==NavPage::FirstMessage);
        cancel_input(false,"feedback-timeout");
        if(return_to_navigator) {s_input_modal=InputModal::SETTINGS;s_settings_phase="NAV";}
        sync_display_page();draw_status();
    }
    if (s_input_modal!=InputModal::NONE && now_ms-s_modal_redraw>=1000) {s_modal_redraw=now_ms;draw_status("modal_tick");}
    if (s_message_visible && now_ms >= s_message_until_ms) {
        s_message_visible = false;
        draw_status();
    }
    if (s_pi.selected && std::strcmp(s_pi.state, "RUNNING") == 0) {
        const uint64_t second = now_ms / 1000;
        if (second != s_last_pi_elapsed_second) {
            s_last_pi_elapsed_second = second;
            s_pi_redraw_pending = true;
        }
    }
    if(!page_overlay_visible() && s_ui.app==wearable::App::Watch) {
        uint64_t elapsed=s_pi.elapsed_seconds;
        const bool running=s_cloud_connected && s_pi.selected && !strcmp(s_pi.state,"RUNNING");
        if(running && now_ms>=s_pi.received_at_ms) elapsed+=(now_ms-s_pi.received_at_ms)/1000;
        // Compare against the last frame, so another status redraw in the same
        // minute cannot cause an extra clock repaint. Offline hides the timer.
        if(s_clock.local_ms(now_ms)/60000!=s_last_watch_clock_minute ||
           (running && elapsed!=s_last_watch_elapsed)) draw_status("shell_clock_tick");
    } else {
        const uint64_t shell_interval=!strcmp(s_pi.state,"RUNNING")?1000:6000;
        if(now_ms/shell_interval!=s_last_shell_tick && !page_overlay_visible() && s_ui.app!=wearable::App::Monitor) {
            s_last_shell_tick=now_ms/shell_interval;draw_status("shell_clock_tick");
        }
    }
    if(watch_snapshot_needs_redraw(s_page,shell_app(),page_overlay_visible(),s_watch_task_redraw_pending))
        draw_status("watch_task");
    if (s_pi.selected && agent_snapshot_needs_redraw(s_page,page_overlay_visible(),s_pi_redraw_pending,shell_app()) &&
        now_ms - s_last_pi_redraw_ms >= PI_REDRAW_MIN_INTERVAL_MS) {
        s_pi_redraw_pending = false;
        s_last_pi_redraw_ms = now_ms;
        draw_status("agent_activity_or_timer");
    }
}

board_button_t board_take_button_click(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    auto result=s_page_click;s_page_click=BOARD_BUTTON_NONE;return result;
}

void board_approval_request(const char *id, const char *agent, const char *title, const char *detail, unsigned /*expires_in*/)
{
    lock_display();
    const uint64_t now = esp_timer_get_time()/1000;
    if (s_approval.receive({id, agent, title, detail, 0}, now)) {
        stop_speech();
        cancel_input(true,"approval");
        s_page_click=BOARD_BUTTON_NONE;
        s_message_visible = false;
        draw_status();
    }
    unlock_display();
}

void board_approval_cancelled(const char *id, const char *state)
{
    lock_display();
    if (s_approval.finish(id, state, esp_timer_get_time()/1000)) draw_status();
    if (s_approval_action.id == id) s_approval_action = {};
    unlock_display();
}

bool board_take_approval_response(char *id, size_t size, bool *approve)
{
    lock_display();
    const bool ready = !s_approval_action.id.empty();
    if (ready) {
        strlcpy(id, s_approval_action.id.c_str(), size);
        *approve = s_approval_action.approve;
        s_approval_action = {};
    }
    unlock_display();
    return ready;
}

bool board_take_control_message(char *out, size_t capacity) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    if (s_control_queue.empty()) return false;
    strlcpy(out,s_control_queue.front().c_str(),capacity);s_control_queue.pop_front();return true;
}
void board_voice_interrupted(uint64_t id) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    if (id!=s_voice_serial || s_input_modal!=InputModal::VOICE) return;
    cancel_input(false,"voice-link-interrupted");
    s_input_modal=InputModal::ERROR;
    s_modal_error="Voice interrupted\nTry again";
    draw_status();
}
void board_capture_finished(uint64_t id, bool cancelled) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    ESP_LOGI(TAG,"[VOICE] capture-finished id=%016llx cancelled=%d modal=%s active_match=%d awaiting_confirmation=%d",
             static_cast<unsigned long long>(id),cancelled,modal_name(s_input_modal),
             id==s_voice_serial,s_voice_phase=="AWAITING_CONFIRMATION");
    if (id!=s_voice_serial || s_input_modal!=InputModal::VOICE || s_voice_phase=="AWAITING_CONFIRMATION" || s_voice_phase=="Recording too short" || s_voice_phase=="SENDING") return;
    s_voice_phase=cancelled?"Capture cancelled":"TRANSCRIBING";draw_status();
}
// Post-save step for Speech Mode. Double A returns to the original Shell page
// after a short SAVED confirmation; the Settings chain keeps its own page.
void speech_mode_saved() {
    if (s_speech_direct) {
        const std::string saved=SpeechMenu::mode_name(s_speech_menu.saved_mode);
        s_speech_direct=false;
        cancel_input(false,"speech-mode-saved");
        board_show_message("SPEECH MODE",("SAVED / "+saved).c_str());
    } else s_settings_phase="SPEECH";
}
// Speech mode preference only. Playback is FROZEN: speech_start/chunk/end/
// cancel/error/request are never advertised or executed.
bool speech_settings_message(cJSON *root, const std::string &type) {
    if (type != "speech_settings") return false;
    const auto mode=js(root,"mode");
    const bool target_ok=js(root,"target")==selected_target();
    ESP_LOGI(TAG,"[SPEECH] settings rx mode=%s target_match=%d request_match=%d phase=%s",
             mode.c_str(),target_ok,js(root,"request_id")==s_speech_request,s_settings_phase.c_str());
    if (!target_ok) return true;
    s_speech_menu.load(mode);
    // Only the matching request completes an in-flight save; an unsolicited or
    // stale settings frame must not resolve it.
    if (s_input_modal==InputModal::SETTINGS && s_settings_phase=="SAVING SPEECH" &&
        js(root,"request_id")==s_speech_request) speech_mode_saved();
    return true;
}
// Called with the display lock; chunks only validate/decode/enqueue, never touch the codec.
// Dead playback path retained for a future Speech Output round. Settings frames
// are handled by speech_settings_message(); this function stays uncalled so no
// speech_start/chunk/end is ever executed by firmware 0.6.3.
[[maybe_unused]] static bool speech_message(cJSON *root, const std::string &type) {
    const auto id=js(root,"speech_id");
    auto number=[&](const char *key) {const auto *v=cJSON_GetObjectItemCaseSensitive(root,key);return cJSON_IsNumber(v)?v->valuedouble:-1;};
    if (type=="speech_start") {
        if (s_approval.modal() || (s_input_modal!=InputModal::NONE && !(s_input_modal==InputModal::VOICE && s_voice_phase=="SENT")) || js(root,"target")!=selected_target()) {ESP_LOGW(TAG,"[DIAG_SPEECH] rejected_start approval=%d modal=%s target_match=%d",s_approval.modal(),modal_name(s_input_modal),js(root,"target")==selected_target());stop_speech();return true;}
        if (s_input_modal==InputModal::VOICE) cancel_input(false,"speech-preemption");
        if (s_speech.start(id,number("sample_rate"),number("bits"),number("channels"))) {
            if (++s_speech_generation==0) ++s_speech_generation;
            if (!audio_backend_speech_start(s_speech_generation)) stop_speech();
            else {
                s_speech_text=js(root,"text").substr(0,1200);
                ESP_LOGI(TAG,"[SPEECH] start id=%s generation=%u source=%s rate=44100 bits=16 channels=1",id.c_str(),
                         s_speech_generation,js(root,"source").c_str());
            }
        }
    } else if (type=="speech_chunk") {
        if (s_speech.id.empty() || id!=s_speech.id) return false;
        const auto audio=js(root,"audio");size_t size=0;
        static uint8_t decoded[AUDIO_SPEECH_BYTES];
        const bool valid=audio.size()<=9408 && mbedtls_base64_decode(decoded,sizeof(decoded),&size,
            reinterpret_cast<const unsigned char *>(audio.data()),audio.size())==0;
        if (!valid || !s_speech.chunk(id,number("sequence"),size) || !audio_backend_speech_push(s_speech_generation,decoded,size)) stop_speech();
        return false;
    } else if (type=="speech_end") {
        if (s_speech.id.empty() || id!=s_speech.id) return false;
        const bool valid=s_speech.end(id,number("chunks"));
        const bool queued=valid && audio_backend_speech_push(s_speech_generation,nullptr,0,true);
        ESP_LOGI(TAG,"[SPEECH] end id=%s chunks=%.0f valid=%d queued=%d",id.c_str(),number("chunks"),valid,queued);
        if (!queued) stop_speech();
    } else if (type=="speech_cancel") {
        if (!id.empty() && id==s_speech.id) stop_speech(false);
    } else if (type=="speech_error") {
        stop_speech(false);
        if (!s_approval.modal() && s_input_modal==InputModal::NONE) {s_input_modal=InputModal::ERROR;s_modal_error="Speech unavailable";++s_ui_epoch;log_modal_transition(InputModal::NONE,"speech-error");}
    }
    return true;
}

bool accept_voice_transcript(const std::string& text) {
    if(s_voice_phase!="TRANSCRIBING")return false;
    if(text.empty() || text.find_first_not_of(" \r\n\t")==std::string::npos || text.size()>4000) {s_voice_phase="Transcription failed";s_transcript.clear();return false;}
    s_transcript=text;s_voice_phase="AWAITING_CONFIRMATION";++s_ui_epoch;
    if(s_voice_send_mode==wearable::VoiceSendMode::Direct) {voice_command("voice_decision","send");s_voice_phase="SENDING";++s_ui_epoch;}
    return true;
}
void board_control_message(const char *json) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    auto *root=cJSON_Parse(json);if (!root) return;
    const auto type=js(root,"type");
    if (type.rfind("voice_",0)==0 || type.rfind("settings_",0)==0 || type=="speech_settings")
        ESP_LOGI(TAG,"[CONTROL] rx type=%s state=%s modal=%s voice_match=%d settings_match=%d",
                 type.c_str(),js(root,"state").c_str(),modal_name(s_input_modal),
                 js(root,"voice_id")==s_voice_id,js(root,"request_id")==s_settings_id);
    if(navigator_message(root,type)) {cJSON_Delete(root);draw_status();return;}
    if(history_message(root,type)) {cJSON_Delete(root);draw_status();return;}
    if (speech_settings_message(root,type)) {cJSON_Delete(root);draw_status();return;}
    if (type.rfind("speech_",0)==0) {
        const bool redraw=speech_message(root,type);
        cJSON_Delete(root);if(redraw) draw_status();return;
    }
    if (type.rfind("voice_",0)==0 && s_input_modal==InputModal::VOICE && js(root,"voice_id")==s_voice_id) {
        if (type=="voice_error") {audio_backend_record_stop(true);s_voice_phase="VOICE ERROR\n"+js(root,"message");s_transcript.clear();}
        else if (type=="voice_transcript" && s_voice_phase=="TRANSCRIBING") {
            accept_voice_transcript(js(root,"transcript"));
            ESP_LOGI(TAG,"[VOICE] transcript-ready bytes=%u non_ascii=%d",static_cast<unsigned>(s_transcript.size()),
                     std::any_of(s_transcript.begin(),s_transcript.end(),[](unsigned char c){return c>=0x80;}));
            unsigned fallback=0, missing=0;
            for (size_t offset=0;offset<s_transcript.size();) {
                const size_t size=utf8_character_size(s_transcript.data()+offset,s_transcript.size()-offset);
                const auto first=static_cast<unsigned char>(s_transcript[offset]);
                uint32_t code=size==1?first:first & ((1U << (7-size))-1U);
                for (size_t i=1;i<size;++i) code=(code<<6)|(static_cast<unsigned char>(s_transcript[offset+i])&0x3FU);
                lgfx::FontMetrics metrics;
                if (code>0xFFFF) ++missing;
                else if (code>=0x20 && !fonts::efontCN_16.updateFontMetric(&metrics,code)) {
                    if (fonts::efontTW_16.updateFontMetric(&metrics,code)) ++fallback;
                    else ++missing;
                }
                offset+=size;
            }
            ESP_LOGI(TAG,"[VOICE] glyph-coverage traditional_fallback=%u missing=%u",fallback,missing);

        }
        else if (js(root,"state")=="CANCELLED" && s_voice_phase!="Recording too short") cancel_input(false,"gateway-cancelled");
        else if (js(root,"state")=="SENT") {s_voice_phase="SENT";s_transcript.clear();s_feedback_until=esp_timer_get_time()/1000+1000;}
        else if (js(root,"state")=="TRANSCRIBING" && s_voice_phase!="Recording too short" && s_voice_phase!="SENDING") {audio_backend_record_stop(false);s_voice_phase="TRANSCRIBING";}
    } else if (type.rfind("settings_",0)==0 && s_input_modal==InputModal::SETTINGS && js(root,"request_id")==s_settings_id) {
        if (type=="settings_error") {s_settings_phase="UPDATE FAILED";s_models.clear();s_modal_error=js(root,"message");}
        else if (js(root,"target")==selected_target()) {
            s_models.clear();s_model_index=0;s_effort_index=0;
            const auto current=js(root,"model"), effort=js(root,"effort");
            cJSON *item=nullptr;
            cJSON_ArrayForEach(item,cJSON_GetObjectItemCaseSensitive(root,"available_models")) {
                if (s_models.size()>=6) break;
                QuickModel m; m.id=js(item,"id");m.name=js(item,"name");m.default_effort=js(item,"default_effort");
                cJSON *e=nullptr;cJSON_ArrayForEach(e,cJSON_GetObjectItemCaseSensitive(item,"available_efforts"))
                    if (cJSON_IsString(e) && m.efforts.size()<32) m.efforts.emplace_back(e->valuestring);
                if (m.id==current) {s_model_index=s_models.size();if (!effort.empty()) m.default_effort=effort;}
                s_models.push_back(m);
            }
            // Only authoritative Gateway responses update the Agent page.
            strlcpy(s_pi.model,current.c_str(),sizeof(s_pi.model));strlcpy(s_pi.effort,effort.c_str(),sizeof(s_pi.effort));
            s_settings_phase=type=="settings_updated"?"MODEL UPDATED":"MODEL";
            if(s_permission_settings) {load_permissions(root);}
            else if(s_effort_settings && !s_models.empty() && !s_models[s_model_index].efforts.empty()) {
                s_settings_phase="EFFORT";const auto& m=s_models[s_model_index];
                for(size_t i=0;i<m.efforts.size();++i)if(m.efforts[i]==effort)s_effort_index=i;
            }
            // Keep the result stable so Long A can return to Agent Settings.
            if (type=="settings_updated") s_feedback_until=0;
            ++s_ui_epoch;
        }
    }
    cJSON_Delete(root);draw_status();
}

bool board_voice_active(uint64_t id) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    return s_input_modal==InputModal::VOICE && s_voice_serial==id && s_voice_phase!="Recording too short";
}

void board_agent_activity(const char *agent, const char *session_id, const char *type, const char *activity, const char *recent) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    const bool accepted=s_pi.selected && std::strcmp(s_pi.agent,agent)==0 && std::strcmp(s_pi.session_id,session_id)==0;
    ESP_LOGI(TAG,"[AGENT] activity agent=%s session=%s accepted=%d kind=%s bytes=%u page=%s modal=%s",
             agent,session_id,accepted,type,static_cast<unsigned>(std::strlen(activity)),page_name(s_page),modal_name(s_input_modal));
    if (!accepted) return;
    if (recent) {
        strlcpy(s_pi.activity_type,type,sizeof(s_pi.activity_type));
        strlcpy(s_pi.latest_activity,activity,sizeof(s_pi.latest_activity));
        set_pi_tail(recent);s_pi_redraw_pending=true;
    } else board_append_pi_activity(type,activity);
}

void board_sync_utc_time(double server_time_ms, int timezone_offset_minutes)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    const bool accepted=s_clock.sync(server_time_ms,esp_timer_get_time()/1000,timezone_offset_minutes);
    esp_err_t rtc_result=ESP_ERR_INVALID_ARG,prefs_result=ESP_OK;
    if(accepted) {
        rtc_result=s_rtc.write_utc_ms(uint64_t(server_time_ms));
        if(s_ui.preferences.timezone_offset_minutes!=timezone_offset_minutes) {
            s_ui.preferences.timezone_offset_minutes=timezone_offset_minutes;
            s_ui.draft.timezone_offset_minutes=timezone_offset_minutes;
            prefs_result=preferences_save(s_saved_preferences,s_ui.preferences);
        }
    }
    ESP_LOGI(TAG,"[TIME] gateway UTC accepted=%d offset_minutes=%d demo_display_offset_minutes=%d rtc_write=%s timezone_save=%s",
        accepted,timezone_offset_minutes,wearable::DEMO_TIMEZONE_OFFSET_MINUTES,
        esp_err_to_name(rtc_result),esp_err_to_name(prefs_result));
}

bool board_voice_context(uint64_t id,char* out,size_t capacity) {
    struct Guard {Guard(){lock_display();}~Guard(){unlock_display();}} guard;
    if(id!=s_voice_serial || s_voice_context.empty() || s_voice_context.size()>=capacity)return false;
    strlcpy(out,s_voice_context.c_str(),capacity);return true;
}
bool board_accept_target(const char* json) {
    struct Guard {Guard(){lock_display();}~Guard(){unlock_display();}} guard;
    auto* root=cJSON_Parse(json);if(!root)return false;
    auto* generation=cJSON_GetObjectItemCaseSensitive(root,"target_generation");
    bool accept=!s_nav.target_select || (cJSON_IsNumber(generation) && generation->valuedouble>=s_nav.generation);
    if(accept && cJSON_IsNumber(generation)) {
        if(s_nav.generation!=uint64_t(generation->valuedouble) && s_input_modal==InputModal::VOICE)cancel_input(true,"target-generation-changed");
        s_nav.generation=generation->valuedouble;
        if(s_nav.ack_pending && s_nav.ack_generation==s_nav.generation) {
            s_nav.ack_pending=false;cancel_input(false,"target-selected-snapshot");
            s_ui.app=wearable::App::Agent;sync_display_page();
        }
        const bool codex=js(root,"agent")=="codex";
        s_ui.agent_capabilities(s_nav.catalog && s_nav.target_select,codex || !js(root,"effort").empty(),s_nav.permissions && codex,s_nav.history);
    }
    cJSON_Delete(root);return accept;
}
