diff infinite_canvas/main.c @ 281:c57149ad216e default tip

Copilot-Session: f68442b1-fa8f-46a0-9689-81710613bbd4
author MrJuneJune <me@mrjunejune.com>
date Tue, 18 Aug 2026 22:18:15 -0700
parents 49e9e591c9bb
children
line wrap: on
line diff
--- a/infinite_canvas/main.c	Tue Aug 18 19:14:53 2026 -0700
+++ b/infinite_canvas/main.c	Tue Aug 18 22:18:15 2026 -0700
@@ -1,5 +1,7 @@
+#include "infinite_canvas/agent_response.h"
 #include "infinite_canvas/agent_service.h"
 #include "infinite_canvas/canvas.h"
+#include "infinite_canvas/scene_store.h"
 #include "infinite_canvas/web_surface.h"
 
 #if defined(INFINITE_CANVAS_DEV_UI)
@@ -27,9 +29,14 @@
     const char *p_screenshot_path;
     uint32 frame_count;
     uint32 screenshot_frame;
-    uint32 pending_conversation_id;
+    Vector2 pending_indicator_position;
+    Vector2 pending_indicator_drag_offset;
     uint32 dictation_entity_id;
     boolean agent_initialized;
+    boolean pending_indicator_visible;
+    boolean pending_indicator_hovered;
+    boolean pending_indicator_dragging;
+    float pending_indicator_hover_amount;
     boolean dictation_overlay_visible;
     boolean dictation_listening;
     boolean dictation_requested_active;
@@ -46,48 +53,6 @@
 
 static Canvas_App *g_app = NULL;
 
-static const char *Canvas_App_JSON_String(
-    const char *p_json,
-    const char *p_key,
-    char *p_output,
-    size_t output_capacity)
-{
-    char needle[128];
-    snprintf(needle, sizeof(needle), "\"%s\"", p_key);
-    const char *p_cursor = strstr(p_json, needle);
-    if (!p_cursor) return NULL;
-    p_cursor = strchr(p_cursor + strlen(needle), ':');
-    if (!p_cursor) return NULL;
-    while (*++p_cursor == ' ') {}
-    if (*p_cursor != '"') return NULL;
-    p_cursor++;
-    size_t output = 0;
-    while (*p_cursor && *p_cursor != '"' && output + 1 < output_capacity) {
-        if (*p_cursor == '\\' && p_cursor[1]) {
-            p_cursor++;
-            if (*p_cursor == 'n') p_output[output++] = '\n';
-            else if (*p_cursor == 't') p_output[output++] = '\t';
-            else if (*p_cursor != 'r') p_output[output++] = *p_cursor;
-        } else {
-            p_output[output++] = *p_cursor;
-        }
-        p_cursor++;
-    }
-    p_output[output] = '\0';
-    return p_output;
-}
-
-static uint32 Canvas_App_JSON_Uint(const char *p_json, const char *p_key)
-{
-    char needle[128];
-    snprintf(needle, sizeof(needle), "\"%s\"", p_key);
-    const char *p_cursor = strstr(p_json, needle);
-    if (!p_cursor) return 0;
-    p_cursor = strchr(p_cursor + strlen(needle), ':');
-    if (!p_cursor) return 0;
-    return (uint32)strtoul(p_cursor + 1, NULL, 10);
-}
-
 static Canvas_Entity *Canvas_App_Find_Entity(uint32 entity_id)
 {
     for (size_t index = 0;
@@ -100,38 +65,6 @@
     return NULL;
 }
 
-static void Canvas_App_Set_Conversation_Text(
-    Canvas_Entity *p_entity,
-    const char *p_prompt,
-    const char *p_speaker,
-    const char *p_response)
-{
-    size_t length = 0;
-    int32 written = snprintf(
-        p_entity->text,
-        sizeof(p_entity->text),
-        "You\n");
-    if (written > 0) length = (size_t)written;
-    written = snprintf(
-        p_entity->text + length,
-        sizeof(p_entity->text) - length,
-        "%.*s\n\n%s\n",
-        900,
-        p_prompt,
-        p_speaker);
-    if (written > 0) length += (size_t)written;
-    if (length >= sizeof(p_entity->text)) {
-        length = sizeof(p_entity->text) - 1;
-    }
-    snprintf(
-        p_entity->text + length,
-        sizeof(p_entity->text) - length,
-        "%.*s",
-        900,
-        p_response);
-    Canvas_Conversation_Scroll_To_End(p_entity, g_app->font);
-}
-
 static boolean Canvas_App_Submit_Prompt(const char *p_prompt)
 {
     if (!p_prompt || !p_prompt[0]) return FALSE;
@@ -151,85 +84,140 @@
         sizeof(g_app->pending_prompt),
         "%s",
         p_prompt);
-    Vector2 position = Canvas_Camera_Screen_To_World(
+    g_app->pending_indicator_position = Canvas_Camera_Screen_To_World(
         &g_app->camera,
         (Vector2){
-            (float)g_app->camera.viewport_width * 0.5f - 210.0f,
+            (float)g_app->camera.viewport_width * 0.5f + 240.0f,
             (float)g_app->camera.viewport_height * 0.5f - 150.0f,
         });
-    g_app->pending_conversation_id = Canvas_Scene_Add_Conversation(
-        &g_app->scene,
-        "Routing thought...",
-        p_prompt,
-        "Copilot is deciding whether to create or extend a session.",
-        position);
-    Canvas_Entity *p_pending =
-        Canvas_App_Find_Entity(g_app->pending_conversation_id);
-    if (p_pending) p_pending->agent_working = TRUE;
+    g_app->pending_indicator_visible = TRUE;
+    g_app->pending_indicator_hovered = FALSE;
+    g_app->pending_indicator_dragging = FALSE;
+    g_app->pending_indicator_hover_amount = 0.0f;
     return TRUE;
 }
 
+static uint32 Canvas_App_Materialize_Agent_Entities(
+    Canvas_Agent_Response *p_response)
+{
+    uint32 created = 0;
+    for (uint32 index = 0; index < p_response->entity_count; index++) {
+        Canvas_Agent_Entity_Spec *p_spec = &p_response->entities[index];
+        Vector2 position = p_spec->has_position
+            ? p_spec->position
+            : (Vector2){
+                g_app->pending_indicator_position.x +
+                    (float)(index % 2) * 280.0f,
+                g_app->pending_indicator_position.y +
+                    (float)(index / 2) * 240.0f,
+            };
+        if (!Canvas_Scene_Add(&g_app->scene, p_spec->type, position)) {
+            break;
+        }
+        Canvas_Entity *p_entity = &g_app->scene.p_entities[
+            Dowa_Array_Length(g_app->scene.p_entities) - 1];
+        if (p_spec->label[0]) {
+            snprintf(
+                p_entity->label,
+                sizeof(p_entity->label),
+                "%s",
+                p_spec->label);
+        }
+        if (p_spec->text[0]) {
+            snprintf(
+                p_entity->text,
+                sizeof(p_entity->text),
+                "%s",
+                p_spec->text);
+            p_entity->text_cursor = (int32)strlen(p_entity->text);
+            p_entity->text_selection_anchor = p_entity->text_cursor;
+        }
+        if (p_spec->has_size) p_entity->size = p_spec->size;
+        if (p_spec->has_value) p_entity->value = p_spec->value;
+        created++;
+    }
+    return created;
+}
+
+static void Canvas_App_Show_Agent_Notification(
+    const char *p_title,
+    const char *p_text)
+{
+    if (!Canvas_Scene_Add(
+            &g_app->scene,
+            CANVAS_ENTITY_NOTIFICATION,
+            g_app->pending_indicator_position)) {
+        return;
+    }
+    Canvas_Entity *p_entity = &g_app->scene.p_entities[
+        Dowa_Array_Length(g_app->scene.p_entities) - 1];
+    if (p_title && p_title[0]) {
+        snprintf(
+            p_entity->label,
+            sizeof(p_entity->label),
+            "%s",
+            p_title);
+    }
+    snprintf(
+        p_entity->text,
+        sizeof(p_entity->text),
+        "%s",
+        p_text ? p_text : "");
+}
+
 static void Canvas_App_Apply_Agent_Response(const char *p_json)
 {
-    char action[32] = {0};
-    char title[128] = {0};
-    char response[CANVAS_ENTITY_TEXT_CAPACITY] = {0};
-    Canvas_App_JSON_String(p_json, "action", action, sizeof(action));
-    Canvas_App_JSON_String(p_json, "title", title, sizeof(title));
-    if (!Canvas_App_JSON_String(
-            p_json,
-            "response",
-            response,
-            sizeof(response))) {
-        snprintf(response, sizeof(response), "%s", p_json);
+    Canvas_Agent_Response parsed;
+    boolean valid = Canvas_Agent_Response_Parse(p_json, &parsed);
+    if (!valid) {
+        memset(&parsed, 0, sizeof(parsed));
+        snprintf(parsed.action, sizeof(parsed.action), "create");
+        snprintf(
+            parsed.presentation,
+            sizeof(parsed.presentation),
+            "conversation");
+        snprintf(
+            parsed.response,
+            sizeof(parsed.response),
+            "%s",
+            p_json);
     }
-    uint32 conversation_id =
-        Canvas_App_JSON_Uint(p_json, "conversation_id");
-    Canvas_Entity *p_pending =
-        Canvas_App_Find_Entity(g_app->pending_conversation_id);
-    if (strcmp(action, "append") == 0 &&
-        conversation_id != 0 &&
+    uint32 created_entities =
+        Canvas_App_Materialize_Agent_Entities(&parsed);
+    if (strcmp(parsed.action, "append") == 0 &&
+        parsed.conversation_id != 0 &&
         Canvas_Scene_Append_Conversation(
             &g_app->scene,
-            conversation_id,
+            parsed.conversation_id,
             g_app->pending_prompt,
-            response)) {
+            parsed.response)) {
         Canvas_Entity *p_conversation =
-            Canvas_App_Find_Entity(conversation_id);
+            Canvas_App_Find_Entity(parsed.conversation_id);
         Canvas_Conversation_Scroll_To_End(
             p_conversation,
             g_app->font);
-        if (p_pending) p_pending->removing = TRUE;
-    } else if (p_pending) {
-        snprintf(
-            p_pending->label,
-            sizeof(p_pending->label),
-            "%s",
-            title[0] ? title : "Copilot session");
-        Canvas_App_Set_Conversation_Text(
-            p_pending,
-            g_app->pending_prompt,
-            "Copilot",
-            response);
-        p_pending->agent_working = FALSE;
+    } else if (strcmp(parsed.presentation, "action") == 0) {
+        if (created_entities == 0) {
+            Canvas_App_Show_Agent_Notification(
+                parsed.title[0] ? parsed.title : "Copilot",
+                parsed.response);
+        }
     } else {
-        Vector2 position = Canvas_Camera_Screen_To_World(
-            &g_app->camera,
-            (Vector2){
-                (float)g_app->camera.viewport_width * 0.5f - 210.0f,
-                (float)g_app->camera.viewport_height * 0.5f - 150.0f,
-            });
+        Vector2 position = {
+            g_app->pending_indicator_position.x - 210.0f,
+            g_app->pending_indicator_position.y - 150.0f,
+        };
         uint32 created_id = Canvas_Scene_Add_Conversation(
             &g_app->scene,
-            title[0] ? title : "Copilot session",
+            parsed.title[0] ? parsed.title : "Copilot session",
             g_app->pending_prompt,
-            response,
+            parsed.response,
             position);
         Canvas_Conversation_Scroll_To_End(
             Canvas_App_Find_Entity(created_id),
             g_app->font);
     }
-    g_app->pending_conversation_id = 0;
+    g_app->pending_indicator_visible = FALSE;
     g_app->pending_prompt[0] = '\0';
 }
 
@@ -421,6 +409,194 @@
         g_app->theme.text_muted);
 }
 
+static void Canvas_App_Draw_Agent_Indicator(void)
+{
+    if (!g_app->pending_indicator_visible) return;
+    Vector2 screen = Canvas_Camera_World_To_Screen(
+        &g_app->camera,
+        g_app->pending_indicator_position);
+    float pulse = 0.5f + 0.5f * sinf((float)GetTime() * 5.0f);
+    float hover = g_app->pending_indicator_hover_amount;
+    float radius = 22.0f + hover * 2.0f;
+    Rectangle bounds = {
+        screen.x - radius,
+        screen.y - radius,
+        radius * 2.0f,
+        radius * 2.0f,
+    };
+    Canvas_Theme_Draw_Shadow(
+        &g_app->theme,
+        bounds,
+        0.5f,
+        16,
+        1.0f,
+        1.0f);
+    DrawCircleV(screen, radius, g_app->theme.surface);
+    DrawCircleLinesV(
+        screen,
+        20.0f + pulse * 2.0f,
+        Fade(g_app->theme.accent, 0.45f + pulse * 0.35f));
+    Canvas_Draw_Lucide_Icon(
+        "sparkles",
+        (Vector2){screen.x - 9.0f, screen.y - 9.0f},
+        0.75f,
+        1.8f,
+        g_app->theme.accent);
+    if (hover > 0.01f) {
+        Rectangle label = {
+            screen.x + 28.0f - (1.0f - hover) * 8.0f,
+            screen.y - 17.0f,
+            118.0f,
+            34.0f,
+        };
+        DrawRectangleRounded(
+            label,
+            0.35f,
+            10,
+            Fade(g_app->theme.surface, hover));
+        DrawRectangleRoundedLinesEx(
+            label,
+            0.35f,
+            10,
+            1.0f,
+            Fade(g_app->theme.border, hover));
+        DrawTextEx(
+            g_app->font,
+            g_app->pending_indicator_dragging
+                ? "Move result"
+                : "Copilot working",
+            (Vector2){label.x + 12.0f, label.y + 10.0f},
+            13.0f,
+            0.0f,
+            Fade(g_app->theme.text, hover));
+    }
+}
+
+static boolean Canvas_App_Update_Agent_Indicator_Input(boolean blocked)
+{
+    if (!g_app->pending_indicator_visible) {
+        g_app->pending_indicator_hovered = FALSE;
+        g_app->pending_indicator_dragging = FALSE;
+        g_app->pending_indicator_hover_amount = 0.0f;
+        return FALSE;
+    }
+    Vector2 pointer = GetMousePosition();
+    Vector2 screen = Canvas_Camera_World_To_Screen(
+        &g_app->camera,
+        g_app->pending_indicator_position);
+    if (g_app->pending_indicator_dragging) {
+        Vector2 target_screen = {
+            pointer.x - g_app->pending_indicator_drag_offset.x,
+            pointer.y - g_app->pending_indicator_drag_offset.y,
+        };
+        g_app->pending_indicator_position =
+            Canvas_Camera_Screen_To_World(
+                &g_app->camera,
+                target_screen);
+        if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) {
+            g_app->pending_indicator_dragging = FALSE;
+        }
+        g_app->pending_indicator_hovered = TRUE;
+    } else {
+        g_app->pending_indicator_hovered =
+            !blocked &&
+            CheckCollisionPointCircle(pointer, screen, 26.0f);
+        if (g_app->pending_indicator_hovered &&
+            IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) {
+            g_app->pending_indicator_dragging = TRUE;
+            g_app->pending_indicator_drag_offset = (Vector2){
+                pointer.x - screen.x,
+                pointer.y - screen.y,
+            };
+        }
+    }
+    float target = g_app->pending_indicator_hovered ? 1.0f : 0.0f;
+    float step = GetFrameTime() * 10.0f;
+    if (g_app->pending_indicator_hover_amount < target) {
+        g_app->pending_indicator_hover_amount = fminf(
+            target,
+            g_app->pending_indicator_hover_amount + step);
+    } else {
+        g_app->pending_indicator_hover_amount = fmaxf(
+            target,
+            g_app->pending_indicator_hover_amount - step);
+    }
+    return g_app->pending_indicator_hovered ||
+        g_app->pending_indicator_dragging;
+}
+
+#if defined(INFINITE_CANVAS_DEV_UI)
+static void Canvas_App_Handle_Dev_Action(Canvas_Dev_UI_Action action)
+{
+    if (action == CANVAS_DEV_UI_ACTION_NONE) return;
+    char path[CANVAS_SCENE_STORE_PATH_CAPACITY];
+    if (!Canvas_Scene_Store_Path(
+            g_app->dev_ui.snapshot_name,
+            path,
+            sizeof(path))) {
+        Canvas_Dev_UI_Set_Snapshot_Status(
+            &g_app->dev_ui,
+            "Use only letters, numbers, - or _");
+        return;
+    }
+    if (action == CANVAS_DEV_UI_ACTION_SAVE_SNAPSHOT) {
+        Canvas_Dev_UI_Set_Snapshot_Status(
+            &g_app->dev_ui,
+            Canvas_Scene_Store_Save(
+                path,
+                &g_app->scene,
+                &g_app->camera)
+                ? "Snapshot saved"
+                : "Snapshot save failed");
+        return;
+    }
+    if (g_app->agent_initialized &&
+        g_app->agent_service.status == CANVAS_AGENT_WORKING) {
+        Canvas_Dev_UI_Set_Snapshot_Status(
+            &g_app->dev_ui,
+            "Wait for the active agent turn before loading");
+        return;
+    }
+    if (!Canvas_Scene_Store_Load(
+            path,
+            &g_app->scene,
+            &g_app->camera)) {
+        Canvas_Dev_UI_Set_Snapshot_Status(
+            &g_app->dev_ui,
+            "Snapshot missing, incompatible, or corrupt");
+        return;
+    }
+    g_app->pending_indicator_visible = FALSE;
+    g_app->pending_prompt[0] = '\0';
+    g_app->dictation_entity_id = 0;
+    g_app->dictation_transcript[0] = '\0';
+    g_app->dictation_partial[0] = '\0';
+    for (size_t index = 0;
+        index < Dowa_Array_Length(g_app->scene.p_entities);
+        index++) {
+        Canvas_Entity *p_entity = &g_app->scene.p_entities[index];
+        if (p_entity->type == CANVAS_ENTITY_TEXT_AREA &&
+            strcmp(p_entity->label, "Dictation") == 0) {
+            g_app->dictation_entity_id = p_entity->id;
+            snprintf(
+                p_entity->text_muted
+                    ? g_app->dictation_partial
+                    : g_app->dictation_transcript,
+                CANVAS_ENTITY_TEXT_CAPACITY,
+                "%s",
+                p_entity->text);
+            break;
+        }
+    }
+    g_app->dictation_overlay_visible =
+        g_app->dictation_transcript[0] ||
+        g_app->dictation_partial[0];
+    Canvas_Dev_UI_Set_Snapshot_Status(
+        &g_app->dev_ui,
+        "Snapshot loaded");
+}
+#endif
+
 static void Canvas_App_Draw_Frame(void)
 {
     int32 width = GetScreenWidth();
@@ -428,13 +604,19 @@
     Canvas_Camera_Set_Viewport(&g_app->camera, width, height);
 
     boolean block_pointer_input = FALSE;
+    boolean dev_ui_editing = FALSE;
 #if defined(INFINITE_CANVAS_DEV_UI)
     block_pointer_input = Canvas_Dev_UI_Contains_Pointer(&g_app->dev_ui);
+    dev_ui_editing = Canvas_Dev_UI_Is_Editing(&g_app->dev_ui);
 #endif
+    block_pointer_input =
+        Canvas_App_Update_Agent_Indicator_Input(block_pointer_input) ||
+        block_pointer_input;
     Canvas_Web_Surface_Set_Dark_Mode(
         &g_app->web_surface,
         g_app->theme.resolved_mode == CANVAS_THEME_DARK ? TRUE : FALSE);
     boolean dictation_hotkey =
+        !dev_ui_editing &&
         !Canvas_Scene_Is_Text_Editing(&g_app->scene) &&
         !Canvas_Web_Chrome_Is_Editing(&g_app->scene) &&
         IsKeyPressed(KEY_M);
@@ -496,6 +678,7 @@
     boolean keyboard_focus =
         Canvas_Scene_Is_Text_Editing(&g_app->scene) ||
         Canvas_Web_Chrome_Is_Editing(&g_app->scene) ||
+        dev_ui_editing ||
         Canvas_Web_Surface_Is_Focused(&g_app->web_surface);
     if (!keyboard_focus && IsKeyPressed(KEY_P)) {
         Canvas_Scene_Toggle_Selected_Pin(&g_app->scene, &g_app->camera);
@@ -614,31 +797,12 @@
     if (agent_status == CANVAS_AGENT_READY) {
         Canvas_App_Apply_Agent_Response(agent_response);
     } else if (agent_status == CANVAS_AGENT_ERROR) {
-        Canvas_Entity *p_pending =
-            Canvas_App_Find_Entity(g_app->pending_conversation_id);
-        if (p_pending) {
-            snprintf(p_pending->label, sizeof(p_pending->label), "Copilot unavailable");
-            Canvas_App_Set_Conversation_Text(
-                p_pending,
-                g_app->pending_prompt,
-                "System",
+        if (g_app->pending_prompt[0]) {
+            Canvas_App_Show_Agent_Notification(
+                "Copilot unavailable",
                 agent_error);
-            p_pending->agent_working = FALSE;
-        } else if (g_app->pending_prompt[0]) {
-            Vector2 position = Canvas_Camera_Screen_To_World(
-                &g_app->camera,
-                (Vector2){
-                    (float)g_app->camera.viewport_width * 0.5f - 210.0f,
-                    (float)g_app->camera.viewport_height * 0.5f - 150.0f,
-                });
-            Canvas_Scene_Add_Conversation(
-                &g_app->scene,
-                "Copilot unavailable",
-                g_app->pending_prompt,
-                agent_error,
-                position);
         }
-        g_app->pending_conversation_id = 0;
+        g_app->pending_indicator_visible = FALSE;
         g_app->pending_prompt[0] = '\0';
     }
 
@@ -687,8 +851,9 @@
         &g_app->scene,
         g_app->font,
         &g_app->theme);
+    Canvas_App_Draw_Agent_Indicator();
 #if defined(INFINITE_CANVAS_DEV_UI)
-    Canvas_Dev_UI_Draw(
+    Canvas_Dev_UI_Action dev_action = Canvas_Dev_UI_Draw(
         &g_app->dev_ui,
         &g_app->camera,
         &g_app->scene,
@@ -697,6 +862,9 @@
 #endif
     Canvas_App_Draw_Listening_Indicator();
     EndDrawing();
+#if defined(INFINITE_CANVAS_DEV_UI)
+    Canvas_App_Handle_Dev_Action(dev_action);
+#endif
     g_app->frame_count++;
 #if !defined(PLATFORM_WEB)
     if (g_app->frame_count == 30) {